@wenbin_wb/dsh-bridge 1.0.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.
package/lib/index.js ADDED
@@ -0,0 +1,499 @@
1
+ // dsh-bridge 主插件(Host)
2
+ //
3
+ // 多渠道访问桥:
4
+ // 1. 局域网访问代理(自动启动,零配置)
5
+ // 2. Cloudflare 隧道(一键获取公网地址)
6
+ // 3. 自建隧道(WebSocket 反向隧道 + Token 认证)
7
+
8
+ import { createServer, request as httpRequest } from 'node:http';
9
+ import { get as httpsGet } from 'node:https';
10
+ import { networkInterfaces, homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
13
+ import QRCode from 'qrcode';
14
+ import { installBridgeRpc } from './bridge-rpc.js';
15
+ import { CustomTunnelClient } from './tunnel-client.mjs';
16
+ import { CloudflaredManager } from './cloudflared-manager.mjs';
17
+
18
+ const name = 'dsh-bridge';
19
+ const inject = ['connection', 'webServer'];
20
+
21
+ const VERSION = '1.0.0';
22
+
23
+ /**
24
+ * 选择最佳局域网 IP
25
+ */
26
+ function selectLanIPv4() {
27
+ const interfaces = networkInterfaces();
28
+ let best = null;
29
+ let bestScore = -1;
30
+
31
+ for (const [ifname, addrs] of Object.entries(interfaces)) {
32
+ if (!addrs) continue;
33
+
34
+ for (const addr of addrs) {
35
+ if (addr.family !== 'IPv4' || addr.internal) continue;
36
+
37
+ let score = 0;
38
+ if (addr.address.startsWith('192.168.')) score += 100;
39
+ else if (addr.address.startsWith('10.')) score += 90;
40
+ else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 90;
41
+
42
+ const lower = ifname.toLowerCase();
43
+ if (!lower.includes('virtual')) score += 50;
44
+ if (!lower.includes('vmware')) score += 50;
45
+ if (!lower.includes('vbox')) score += 50;
46
+ if (lower.includes('eth')) score += 20;
47
+ else if (lower.includes('en')) score += 10;
48
+
49
+ if (score > bestScore) {
50
+ bestScore = score;
51
+ best = addr.address;
52
+ }
53
+ }
54
+ }
55
+
56
+ return best;
57
+ }
58
+
59
+ /**
60
+ * 二维码缓存(带 TTL + LRU)
61
+ */
62
+ class QrCache {
63
+ constructor(ttl = 30 * 60 * 1000, maxSize = 8) {
64
+ this.cache = new Map();
65
+ this.ttl = ttl;
66
+ this.maxSize = maxSize;
67
+ }
68
+
69
+ async get(text) {
70
+ const cached = this.cache.get(text);
71
+ if (cached && Date.now() - cached.time < this.ttl) {
72
+ return cached.data;
73
+ }
74
+
75
+ const qr = await QRCode.toDataURL(text, {
76
+ width: 300,
77
+ margin: 2,
78
+ color: { dark: '#1F2421', light: '#FFFFFF' },
79
+ });
80
+
81
+ this.cache.set(text, { data: qr, time: Date.now() });
82
+
83
+ if (this.cache.size > this.maxSize) {
84
+ const oldest = Array.from(this.cache.entries())
85
+ .sort((a, b) => a[1].time - b[1].time)[0];
86
+ if (oldest) this.cache.delete(oldest[0]);
87
+ }
88
+
89
+ return qr;
90
+ }
91
+
92
+ clear() {
93
+ this.cache.clear();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 非安全上下文(http://LAN-IP:端口)里浏览器没有 crypto.randomUUID,
99
+ * DSH 连接层 mint RPC id 时会抛错,注入 polyfill 修复。
100
+ */
101
+ const RANDOM_UUID_POLYFILL = `<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>`;
102
+ const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
103
+
104
+ function isCompressed(headers) {
105
+ return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
106
+ }
107
+
108
+ /** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
109
+ function loopbackHeaders(headers, targetPort) {
110
+ const authority = `127.0.0.1:${targetPort}`;
111
+ const out = { ...headers };
112
+ out['host'] = authority;
113
+ if (out['origin']) out['origin'] = `http://${authority}`;
114
+ if (out['Origin']) out['Origin'] = `http://${authority}`;
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * HTTP + WebSocket 代理服务器
120
+ * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
121
+ * 否则手机通过局域网访问时 DSH 会把它当未登录的外部请求处理
122
+ */
123
+ class ProxyServer {
124
+ constructor({ localPort, targetPort, logger }) {
125
+ this.localPort = localPort;
126
+ this.targetPort = targetPort;
127
+ this.logger = logger;
128
+ this.server = null;
129
+ this.clientSockets = new Set();
130
+ this.activeConnections = 0;
131
+ }
132
+
133
+ async start() {
134
+ if (this.server) return;
135
+
136
+ this.server = createServer((req, res) => {
137
+ const headers = loopbackHeaders(req.headers, this.targetPort);
138
+ const proxyReq = httpRequest(
139
+ { host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
140
+ (proxyRes) => {
141
+ const contentType = String(proxyRes.headers['content-type'] ?? '');
142
+ // 未压缩的 HTML 文档注入 polyfill
143
+ if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
144
+ const chunks = [];
145
+ proxyRes.on('data', (c) => chunks.push(c));
146
+ proxyRes.on('end', () => {
147
+ let html = Buffer.concat(chunks).toString('utf8');
148
+ if (!html.includes(INJECT_MARK)) {
149
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${RANDOM_UUID_POLYFILL}`);
150
+ }
151
+ const out = Buffer.from(html, 'utf8');
152
+ const outHeaders = { ...proxyRes.headers };
153
+ delete outHeaders['content-length'];
154
+ delete outHeaders['transfer-encoding'];
155
+ outHeaders['content-length'] = String(out.length);
156
+ res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
157
+ res.end(out);
158
+ });
159
+ proxyRes.on('error', () => res.destroy());
160
+ return;
161
+ }
162
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
163
+ proxyRes.pipe(res);
164
+ res.on('close', () => proxyRes.destroy());
165
+ proxyRes.on('error', () => res.destroy());
166
+ proxyRes.on('close', () => { if (!res.writableEnded) res.destroy(); });
167
+ },
168
+ );
169
+ proxyReq.on('error', (err) => {
170
+ this.logger.error('代理请求失败: %s', err.message);
171
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
172
+ res.end(`dsh-bridge: 无法连接 dsh web (127.0.0.1:${this.targetPort}) — ${err.message}`);
173
+ });
174
+ req.pipe(proxyReq);
175
+ });
176
+
177
+ // WebSocket upgrade(DSH 的 /api/events.mux 等流式通道)
178
+ this.server.on('upgrade', (req, socket, head) => {
179
+ const headers = loopbackHeaders(req.headers, this.targetPort);
180
+ const proxyReq = httpRequest({
181
+ host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
182
+ });
183
+ proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
184
+ socket.write('HTTP/1.1 101 Switching Protocols\r\n');
185
+ const raw = [];
186
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
187
+ raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
188
+ }
189
+ socket.write(`${raw.join('\r\n')}\r\n\r\n`);
190
+ if (proxyHead?.length) socket.write(proxyHead);
191
+ proxySocket.pipe(socket);
192
+ socket.pipe(proxySocket);
193
+ const teardown = () => {
194
+ try { proxySocket.destroy(); } catch {}
195
+ try { socket.destroy(); } catch {}
196
+ };
197
+ proxySocket.on('close', teardown);
198
+ socket.on('close', teardown);
199
+ });
200
+ proxyReq.on('response', (proxyRes) => {
201
+ if (proxyRes.statusCode === 101) return;
202
+ try {
203
+ const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
204
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
205
+ raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
206
+ }
207
+ socket.end(raw.join('\r\n') + '\r\n\r\n');
208
+ proxyRes.resume();
209
+ } catch { socket.destroy(); }
210
+ });
211
+ proxyReq.on('error', () => socket.destroy());
212
+ if (head?.length) proxyReq.write(head);
213
+ proxyReq.end();
214
+ socket.on('error', () => socket.destroy());
215
+ });
216
+
217
+ // 跟踪所有连接以便 stop() 时强制关闭
218
+ this.server.on('connection', (sock) => {
219
+ this.clientSockets.add(sock);
220
+ sock.on('close', () => this.clientSockets.delete(sock));
221
+ sock.on('error', () => {});
222
+ });
223
+
224
+ await new Promise((resolve, reject) => {
225
+ this.server.once('error', reject);
226
+ this.server.listen(this.localPort, '0.0.0.0', () => {
227
+ this.logger.info('dsh-bridge: 代理已启动 0.0.0.0:%d -> 127.0.0.1:%d', this.localPort, this.targetPort);
228
+ resolve();
229
+ });
230
+ });
231
+ }
232
+
233
+ async stop() {
234
+ if (!this.server) return;
235
+ for (const s of this.clientSockets) { try { s.destroy(); } catch {} }
236
+ await new Promise((resolve) => this.server.close(() => resolve()));
237
+ this.server = null;
238
+ this.clientSockets.clear();
239
+ this.activeConnections = 0;
240
+ }
241
+
242
+ get port() {
243
+ return this.localPort;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Bridge Service
249
+ */
250
+ class BridgeService {
251
+ constructor({ dshPort, proxyPort, home, customTunnelConfig, logger }) {
252
+ this.dshPort = dshPort;
253
+ this.proxyPort = proxyPort;
254
+ this.home = home;
255
+ this.customTunnelConfig = customTunnelConfig ?? null;
256
+ this.logger = logger;
257
+
258
+ this.qrCache = new QrCache();
259
+ this.proxy = null;
260
+
261
+ this.customTunnel = null;
262
+ this.customTunnelState = { phase: 'idle', detail: '' };
263
+
264
+ this.cloudflared = null;
265
+ this.cloudflaredState = { phase: 'idle', detail: '' };
266
+ }
267
+
268
+ async startProxy() {
269
+ if (this.proxy) return this.proxy;
270
+
271
+ this.proxy = new ProxyServer({
272
+ localPort: this.proxyPort,
273
+ targetPort: this.dshPort,
274
+ logger: this.logger,
275
+ });
276
+
277
+ await this.proxy.start();
278
+ return this.proxy;
279
+ }
280
+
281
+ async getStatus() {
282
+ const lanIp = selectLanIPv4();
283
+ const lanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
284
+
285
+ return {
286
+ version: VERSION,
287
+
288
+ proxy: {
289
+ running: !!this.proxy,
290
+ port: this.proxyPort,
291
+ activeConnections: this.proxy?.activeConnections ?? 0,
292
+ },
293
+
294
+ lan: {
295
+ ip: lanIp,
296
+ url: lanUrl,
297
+ qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
298
+ },
299
+
300
+ cloudflared: {
301
+ running: !!this.cloudflared,
302
+ url: this.cloudflared?.url || null,
303
+ qr: this.cloudflared?.url
304
+ ? await this.qrCache.get(this.cloudflared.url)
305
+ : null,
306
+ state: this.cloudflaredState,
307
+ },
308
+
309
+ customTunnel: {
310
+ configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
311
+ serverUrl: this.customTunnelConfig?.serverUrl ?? '',
312
+ running: !!this.customTunnel?.connected,
313
+ url: this.customTunnel?.publicUrl || null,
314
+ qr: this.customTunnel?.publicUrl
315
+ ? await this.qrCache.get(this.customTunnel.publicUrl)
316
+ : null,
317
+ state: this.customTunnelState,
318
+ },
319
+ };
320
+ }
321
+
322
+ async startCustomTunnel() {
323
+ if (this.customTunnel) {
324
+ throw new Error('自建隧道已在运行');
325
+ }
326
+
327
+ const serverUrl = this.customTunnelConfig?.serverUrl;
328
+ const accessToken = this.customTunnelConfig?.accessToken;
329
+
330
+ if (!serverUrl || !accessToken) {
331
+ throw new Error('缺少配置:请在 cordis.yml 中配置 customTunnel.serverUrl 和 customTunnel.accessToken');
332
+ }
333
+
334
+ this.customTunnel = new CustomTunnelClient({
335
+ serverUrl,
336
+ accessToken,
337
+ localPort: this.proxyPort,
338
+ onStateChange: (state) => {
339
+ this.customTunnelState = state;
340
+ },
341
+ logger: this.logger,
342
+ });
343
+
344
+ await this.customTunnel.connect();
345
+ }
346
+
347
+ stopCustomTunnel() {
348
+ if (this.customTunnel) {
349
+ this.customTunnel.disconnect();
350
+ this.customTunnel = null;
351
+ this.customTunnelState = { phase: 'idle', detail: '' };
352
+ }
353
+ }
354
+
355
+ async startCloudflared() {
356
+ if (this.cloudflared) {
357
+ throw new Error('Cloudflare 隧道已在运行');
358
+ }
359
+
360
+ this.cloudflaredState = { phase: 'connecting', detail: '正在初始化...' };
361
+ this.cloudflared = new CloudflaredManager({
362
+ port: this.proxyPort,
363
+ home: this.home,
364
+ onStateChange: (state) => {
365
+ this.cloudflaredState = state;
366
+ // 出错时自动清理,让用户可以重新开启
367
+ if (state.phase === 'error') {
368
+ this.cloudflared = null;
369
+ }
370
+ },
371
+ logger: this.logger,
372
+ });
373
+
374
+ // 非阻塞启动,立即返回——下载/连接进度通过 onStateChange 推送
375
+ this.cloudflared.start();
376
+ }
377
+
378
+ stopCloudflared() {
379
+ if (this.cloudflared) {
380
+ this.cloudflared.stop();
381
+ this.cloudflared = null;
382
+ this.cloudflaredState = { phase: 'idle', detail: '' };
383
+ }
384
+ }
385
+
386
+ // 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制
387
+ async resetCloudflared() {
388
+ this.stopCloudflared();
389
+ const binDir = join(this.home ?? join(homedir(), '.dsh-bridge'), 'bin');
390
+ const candidates = ['cloudflared.exe', 'cloudflared'];
391
+ for (const name of candidates) {
392
+ const p = join(binDir, name);
393
+ try { await unlink(p); } catch {}
394
+ }
395
+ this.cloudflaredState = { phase: 'idle', detail: '' };
396
+ }
397
+
398
+ // 检查 npm 上是否有新版本
399
+ async checkVersion() {
400
+ return new Promise((resolve) => {
401
+ const req = httpsGet('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', { timeout: 8000 }, (res) => {
402
+ const chunks = [];
403
+ res.on('data', (c) => chunks.push(c));
404
+ res.on('end', () => {
405
+ try {
406
+ const data = JSON.parse(Buffer.concat(chunks).toString());
407
+ resolve({ current: VERSION, latest: data.version ?? null });
408
+ } catch {
409
+ resolve({ current: VERSION, latest: null, error: '解析失败' });
410
+ }
411
+ });
412
+ });
413
+ req.on('error', (e) => resolve({ current: VERSION, latest: null, error: e.message }));
414
+ req.on('timeout', () => { req.destroy(); resolve({ current: VERSION, latest: null, error: '超时' }); });
415
+ });
416
+ }
417
+
418
+ async dispose() {
419
+ this.stopCustomTunnel();
420
+ this.stopCloudflared();
421
+ if (this.proxy) {
422
+ await this.proxy.stop();
423
+ this.proxy = null;
424
+ }
425
+ this.qrCache.clear();
426
+ }
427
+ }
428
+
429
+ /**
430
+ * 插件入口
431
+ */
432
+ function apply(ctx, config = {}) {
433
+ const logger = ctx.logger(name);
434
+ const dshPort = ctx.webServer.port;
435
+
436
+ if (!dshPort) {
437
+ logger.error('webServer port unavailable');
438
+ return;
439
+ }
440
+
441
+ const proxyPort = config.port ?? 3082;
442
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
443
+ const configFile = join(dshHome, 'dsh-bridge', 'config.json');
444
+
445
+ // 从 JSON 文件读取持久化配置
446
+ async function loadConfig() {
447
+ try {
448
+ const raw = await readFile(configFile, 'utf8');
449
+ return JSON.parse(raw);
450
+ } catch {
451
+ return {};
452
+ }
453
+ }
454
+
455
+ // 持久化配置到 JSON 文件
456
+ async function saveConfig(data) {
457
+ await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
458
+ await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
459
+ }
460
+
461
+ const service = new BridgeService({
462
+ dshPort,
463
+ proxyPort,
464
+ home: config.home,
465
+ customTunnelConfig: config.customTunnel ?? null,
466
+ logger,
467
+ });
468
+
469
+ // 启动时读取已保存的自建隧道配置
470
+ loadConfig().then((stored) => {
471
+ if (stored?.customTunnel?.serverUrl) {
472
+ service.customTunnelConfig = stored.customTunnel;
473
+ logger.info('dsh-bridge: loaded saved custom tunnel config');
474
+ }
475
+ }).catch(() => {});
476
+
477
+ const disposeRpc = installBridgeRpc(ctx, {
478
+ service,
479
+ logger,
480
+ saveCustomTunnelConfig: async (serverUrl, accessToken) => {
481
+ const stored = await loadConfig();
482
+ stored.customTunnel = { serverUrl, accessToken };
483
+ await saveConfig(stored);
484
+ service.customTunnelConfig = { serverUrl, accessToken };
485
+ },
486
+ });
487
+
488
+ // 代理随插件自动启动
489
+ void service.startProxy().catch((err) => {
490
+ logger.error('dsh-bridge: proxy start failed: %s', err?.message ?? err);
491
+ });
492
+
493
+ ctx.effect(() => async () => {
494
+ try { disposeRpc(); } catch {}
495
+ await service.dispose();
496
+ }, 'dsh-bridge: stop proxy and tunnels');
497
+ }
498
+
499
+ export { name, inject, apply };