dsh-plugin-weapp-gateway 0.8.23 → 0.8.34
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/LICENSE +21 -21
- package/PROTOCOL.md +1545 -1486
- package/README.md +503 -464
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
- package/bin/setup-ip.mjs +474 -474
- package/cordis.patch.yml +50 -50
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/blog-mobile-gateway.md +542 -542
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
- package/docs/dsh-rc2-mobile-integration.md +155 -155
- package/docs/file-upload-app-integration.md +250 -0
- package/docs/multi-gateway-app-integration.md +141 -125
- package/docs/multi-gateway-phase1-acceptance.md +179 -179
- package/docs/multi-gateway-todo.md +137 -137
- package/docs/remote-gateway-refactor-plan.md +52 -52
- package/docs/runtime-architecture.architecture.json +264 -264
- package/docs/runtime-architecture.html +15001 -15001
- package/docs/runtime-architecture.visual-check.html +32 -32
- package/docs/runtime-architecture.visual-check.json +548 -548
- package/docs/session-agent-preset-app-integration.md +93 -93
- package/docs/turn-outline-app-integration.md +196 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -294
- package/docs/voice-input-implementation.md +219 -219
- package/docs/workspace-changes-mobile-integration.md +32 -32
- package/helper/dsh_mobile_gateway_helper.py +227 -227
- package/lib/account-bridge.mjs +57 -57
- package/lib/client.js +1409 -1361
- package/lib/cloudflare-tunnel.mjs +314 -314
- package/lib/cloudflared-binary.mjs +135 -135
- package/lib/context-bridge.mjs +3338 -1715
- package/lib/cost-meter-bridge.mjs +794 -794
- package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
- package/lib/devices.js +273 -245
- package/lib/dsh-host-adapter.mjs +508 -426
- package/lib/file-upload.mjs +205 -0
- package/lib/gateway-probe.mjs +6 -6
- package/lib/gateway-state.mjs +57 -57
- package/lib/gateway-tools.mjs +36 -36
- package/lib/history-cache.mjs +66 -0
- package/lib/index.mjs +4390 -3803
- package/lib/plugin-management.mjs +251 -233
- package/lib/public-endpoint-check.mjs +61 -61
- package/lib/relay-connector.mjs +213 -181
- package/lib/relay-state.mjs +83 -76
- package/lib/session-follower.mjs +140 -136
- package/lib/session-resume-bridge.mjs +86 -0
- package/lib/session-target.mjs +29 -0
- package/lib/speech-input.mjs +192 -192
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
- package/lib/vendor/cost-meter/net.mjs +183 -183
- package/lib/vendor/cost-meter/opendesign.mjs +204 -204
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
- package/lib/web-restart.mjs +47 -47
- package/lib/wire-json.mjs +7 -7
- package/lib/workspace-changes-bridge.mjs +32 -32
- package/package.json +54 -54
- package/relay/agent-connector.js +588 -569
- package/relay/vendor/tweetnacl.js +2391 -2391
package/relay/agent-connector.js
CHANGED
|
@@ -1,569 +1,588 @@
|
|
|
1
|
-
// DSH Weapp Agent Connector —— 运行在 fnOS/电脑上。
|
|
2
|
-
//
|
|
3
|
-
// 1. 出站 wss 连接中继注册 nodeId(不开任何入站端口,与 dsh-wechat-remote 的
|
|
4
|
-
// outbound-only relay 思路一致);
|
|
5
|
-
// 2. 收到 client-open 后,按客户端请求的子协议拨本地网关
|
|
6
|
-
// ws://127.0.0.1:<port>/ws/mobile(配对码经 Sec-WebSocket-Protocol 透传);
|
|
7
|
-
// 3. E2EE:完成 ClientHello/AgentHello 握手并验签客户端身份后,
|
|
8
|
-
// 解密小程序帧转发给本地网关,网关回帧加密后经中继回传。
|
|
9
|
-
//
|
|
10
|
-
// 用法:node agent-connector.js --relay wss://<你的域名> [--gateway ws://<fnOS内网IP>:3081/ws/mobile]
|
|
11
|
-
// 首次运行生成 ~/.dsh-weapp-relay/identity.json(nodeId + Ed25519 身份密钥),
|
|
12
|
-
// 并打印配对载荷(Base64URL),可直接做成二维码或粘到小程序手动配对。
|
|
13
|
-
|
|
14
|
-
const fs = require('fs');
|
|
15
|
-
const os = require('os');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const crypto = require('crypto');
|
|
18
|
-
let nacl;
|
|
19
|
-
try { nacl = require('tweetnacl'); } catch { nacl = require('./vendor/tweetnacl.js'); }
|
|
20
|
-
const WebSocket = require('ws');
|
|
21
|
-
|
|
22
|
-
// ---------- 配置 ----------
|
|
23
|
-
|
|
24
|
-
function arg(name, fallback) {
|
|
25
|
-
const idx = process.argv.indexOf('--' + name);
|
|
26
|
-
return idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : fallback;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const configuredRelay = (process.env.RELAY_URL || arg('relay', '')).replace(/\/$/, '');
|
|
30
|
-
// Built-in service routing, not a secret. Preserve third-party relay settings.
|
|
31
|
-
const RELAY = ['', 'wss://relay.ahwe.top', 'wss://relay.ahwe.top/relay'].includes(configuredRelay)
|
|
32
|
-
? 'wss://dsh.ahwe.top/relay' : configuredRelay;
|
|
33
|
-
const GATEWAY = process.env.GATEWAY_URL || arg('gateway', 'ws://127.0.0.1:3081/ws/mobile');
|
|
34
|
-
const STATE_DIR = process.env.STATE_DIR || path.join(os.homedir(), '.dsh-weapp-relay');
|
|
35
|
-
const GATEWAY_NAME = process.env.GATEWAY_NAME || arg('name', 'fnOS DSH');
|
|
36
|
-
const MGMT_URL = process.env.MGMT_URL || arg('mgmt', 'http://127.0.0.1:2298');
|
|
37
|
-
|
|
38
|
-
if (!/^wss?:\/\//.test(RELAY)) {
|
|
39
|
-
console.error('用法:node agent-connector.js --relay wss://<中继域名> [--gateway ws://<fnOS内网IP>:3081/ws/mobile]');
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Keep the plugin's single-instance contract and its existing identity folder.
|
|
44
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
45
|
-
const lockFile = path.join(STATE_DIR, 'connector.lock');
|
|
46
|
-
let previous;
|
|
47
|
-
try { previous = JSON.parse(fs.readFileSync(lockFile, 'utf8')); } catch {}
|
|
48
|
-
if (previous && Number.isInteger(previous.pid) && previous.pid !== process.pid) {
|
|
49
|
-
let alive = false;
|
|
50
|
-
try { process.kill(previous.pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
|
|
51
|
-
if (alive) { console.error('Another connector owns this identity; stop it before starting another.'); process.exit(1); }
|
|
52
|
-
}
|
|
53
|
-
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600 });
|
|
54
|
-
process.on('exit', () => {
|
|
55
|
-
try { if (JSON.parse(fs.readFileSync(lockFile, 'utf8')).pid === process.pid) fs.unlinkSync(lockFile); } catch {}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
} catch (e) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
this.
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
if (frame.kind === '
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
function
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
||
|
|
293
|
-
|
|
294
|
-
pipe.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
log(`管道 ${bridgeId}:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
gateway.on('
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
pipe.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
pipe.
|
|
507
|
-
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
return;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1
|
+
// DSH Weapp Agent Connector —— 运行在 fnOS/电脑上。
|
|
2
|
+
//
|
|
3
|
+
// 1. 出站 wss 连接中继注册 nodeId(不开任何入站端口,与 dsh-wechat-remote 的
|
|
4
|
+
// outbound-only relay 思路一致);
|
|
5
|
+
// 2. 收到 client-open 后,按客户端请求的子协议拨本地网关
|
|
6
|
+
// ws://127.0.0.1:<port>/ws/mobile(配对码经 Sec-WebSocket-Protocol 透传);
|
|
7
|
+
// 3. E2EE:完成 ClientHello/AgentHello 握手并验签客户端身份后,
|
|
8
|
+
// 解密小程序帧转发给本地网关,网关回帧加密后经中继回传。
|
|
9
|
+
//
|
|
10
|
+
// 用法:node agent-connector.js --relay wss://<你的域名> [--gateway ws://<fnOS内网IP>:3081/ws/mobile]
|
|
11
|
+
// 首次运行生成 ~/.dsh-weapp-relay/identity.json(nodeId + Ed25519 身份密钥),
|
|
12
|
+
// 并打印配对载荷(Base64URL),可直接做成二维码或粘到小程序手动配对。
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
let nacl;
|
|
19
|
+
try { nacl = require('tweetnacl'); } catch { nacl = require('./vendor/tweetnacl.js'); }
|
|
20
|
+
const WebSocket = require('ws');
|
|
21
|
+
|
|
22
|
+
// ---------- 配置 ----------
|
|
23
|
+
|
|
24
|
+
function arg(name, fallback) {
|
|
25
|
+
const idx = process.argv.indexOf('--' + name);
|
|
26
|
+
return idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const configuredRelay = (process.env.RELAY_URL || arg('relay', '')).replace(/\/$/, '');
|
|
30
|
+
// Built-in service routing, not a secret. Preserve third-party relay settings.
|
|
31
|
+
const RELAY = ['', 'wss://relay.ahwe.top', 'wss://relay.ahwe.top/relay'].includes(configuredRelay)
|
|
32
|
+
? 'wss://dsh.ahwe.top/relay' : configuredRelay;
|
|
33
|
+
const GATEWAY = process.env.GATEWAY_URL || arg('gateway', 'ws://127.0.0.1:3081/ws/mobile');
|
|
34
|
+
const STATE_DIR = process.env.STATE_DIR || path.join(os.homedir(), '.dsh-weapp-relay');
|
|
35
|
+
const GATEWAY_NAME = process.env.GATEWAY_NAME || arg('name', 'fnOS DSH');
|
|
36
|
+
const MGMT_URL = process.env.MGMT_URL || arg('mgmt', 'http://127.0.0.1:2298');
|
|
37
|
+
|
|
38
|
+
if (!/^wss?:\/\//.test(RELAY)) {
|
|
39
|
+
console.error('用法:node agent-connector.js --relay wss://<中继域名> [--gateway ws://<fnOS内网IP>:3081/ws/mobile]');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Keep the plugin's single-instance contract and its existing identity folder.
|
|
44
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
45
|
+
const lockFile = path.join(STATE_DIR, 'connector.lock');
|
|
46
|
+
let previous;
|
|
47
|
+
try { previous = JSON.parse(fs.readFileSync(lockFile, 'utf8')); } catch {}
|
|
48
|
+
if (previous && Number.isInteger(previous.pid) && previous.pid !== process.pid) {
|
|
49
|
+
let alive = false;
|
|
50
|
+
try { process.kill(previous.pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
|
|
51
|
+
if (alive) { console.error('Another connector owns this identity; stop it before starting another.'); process.exit(1); }
|
|
52
|
+
}
|
|
53
|
+
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600 });
|
|
54
|
+
process.on('exit', () => {
|
|
55
|
+
try { if (JSON.parse(fs.readFileSync(lockFile, 'utf8')).pid === process.pid) fs.unlinkSync(lockFile); } catch {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 父进程守护(仅插件内置连接器):DSH 崩溃或强退时,子进程会被过继给 init 继续
|
|
59
|
+
// 存活——它占着 pid 锁、拨已死亡的端口,之后每次 DSH 重启都只会委派给这个僵尸,
|
|
60
|
+
// 手机的中继线路永久重连不上。插件启动时注入 PARENT_PID;手动运行
|
|
61
|
+
// (systemd / nohup)不设置该变量,行为完全不变。POSIX 上父进程死亡后 ppid 会被
|
|
62
|
+
// 改写,同时兜住 pid 复用。
|
|
63
|
+
const PARENT_PID = Number(process.env.PARENT_PID) || 0;
|
|
64
|
+
if (PARENT_PID > 0) {
|
|
65
|
+
const startupPpid = process.ppid;
|
|
66
|
+
const parentGone = () => {
|
|
67
|
+
if (process.ppid !== startupPpid) return true;
|
|
68
|
+
try { process.kill(PARENT_PID, 0); return false; } catch (e) { return e.code === 'ESRCH'; }
|
|
69
|
+
};
|
|
70
|
+
setInterval(() => {
|
|
71
|
+
if (!parentGone()) return;
|
|
72
|
+
console.log('[' + new Date().toISOString() + '] parent process ' + PARENT_PID + ' exited; connector shutting down');
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}, 5000).unref?.();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- 身份 ----------
|
|
78
|
+
|
|
79
|
+
const PROTOCOL = 'dsh-weapp-e2ee-v1';
|
|
80
|
+
const PACKET_CLIENT_HELLO = 1;
|
|
81
|
+
const PACKET_AGENT_HELLO = 2;
|
|
82
|
+
const PACKET_SEALED = 3;
|
|
83
|
+
const CLEAR_DATA = 3;
|
|
84
|
+
|
|
85
|
+
function loadIdentity() {
|
|
86
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
87
|
+
const file = path.join(STATE_DIR, 'identity.json');
|
|
88
|
+
try {
|
|
89
|
+
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
90
|
+
if (saved.nodeId && saved.signSecretKey && saved.signPublicKey) return saved;
|
|
91
|
+
} catch (e) { /* 首次运行 */ }
|
|
92
|
+
const keyPair = nacl.sign.keyPair();
|
|
93
|
+
const identity = {
|
|
94
|
+
nodeId: crypto.randomUUID(),
|
|
95
|
+
deviceId: crypto.randomUUID(),
|
|
96
|
+
signPublicKey: Buffer.from(keyPair.publicKey).toString('base64'),
|
|
97
|
+
signSecretKey: Buffer.from(keyPair.secretKey).toString('base64')
|
|
98
|
+
};
|
|
99
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2), { mode: 0o600 });
|
|
100
|
+
return identity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const identity = loadIdentity();
|
|
104
|
+
|
|
105
|
+
// 网关长期凭据:E2EE 解密后的 paired 帧中提取,重连拨号时注入 Authorization。
|
|
106
|
+
// 仅存于 fnOS 本机(与 iOS 存 Keychain 同一信任边界)。
|
|
107
|
+
function loadCreds() {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'credentials.json'), 'utf8'));
|
|
110
|
+
} catch (e) { return {}; }
|
|
111
|
+
}
|
|
112
|
+
function saveCreds(creds) {
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
115
|
+
fs.writeFileSync(path.join(STATE_DIR, 'credentials.json'), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
116
|
+
} catch (e) { /* 忽略 */ }
|
|
117
|
+
}
|
|
118
|
+
function captureToken(text, deviceId) {
|
|
119
|
+
try {
|
|
120
|
+
const frame = JSON.parse(text);
|
|
121
|
+
if (frame && frame.kind === 'paired' && frame.token) {
|
|
122
|
+
const creds = loadCreds();
|
|
123
|
+
const key = deviceId || GATEWAY;
|
|
124
|
+
if (creds[key] !== frame.token) {
|
|
125
|
+
creds[key] = frame.token;
|
|
126
|
+
saveCreds(creds);
|
|
127
|
+
log('已保存网关长期凭据(用于重连)');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch (e) { /* 非 JSON 帧 */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- E2EE(与小程序端 utils/relay-e2ee.js 镜像) ----------
|
|
134
|
+
|
|
135
|
+
function concat(...parts) {
|
|
136
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
137
|
+
let offset = 0;
|
|
138
|
+
for (const part of parts) { out.set(part, offset); offset += part.length; }
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function utf8(text) { return new TextEncoder().encode(text); }
|
|
143
|
+
function utf8Decode(bytes) { return new TextDecoder().decode(bytes); }
|
|
144
|
+
|
|
145
|
+
function counterBytes(value) {
|
|
146
|
+
const out = new Uint8Array(8);
|
|
147
|
+
let v = value;
|
|
148
|
+
for (let i = 7; i >= 0; i -= 1) { out[i] = v & 255; v = Math.floor(v / 256); }
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readCounter(bytes, offset) {
|
|
153
|
+
let value = 0;
|
|
154
|
+
for (let i = 0; i < 8; i += 1) value = value * 256 + bytes[offset + i];
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class AgentSession {
|
|
159
|
+
constructor(nodeId, identitySecretKey) {
|
|
160
|
+
this.nodeId = nodeId;
|
|
161
|
+
this.identitySecretKey = identitySecretKey;
|
|
162
|
+
this.ephemeral = nacl.box.keyPair();
|
|
163
|
+
this.rxCounter = 0;
|
|
164
|
+
this.txCounter = 0;
|
|
165
|
+
this.ready = false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 输入 ClientHello,返回 AgentHello 二进制;失败返回 null。 */
|
|
169
|
+
acceptClientHello(packet) {
|
|
170
|
+
if (!packet || packet.length !== 1 + 32 + 16 || packet[0] !== PACKET_CLIENT_HELLO) return null;
|
|
171
|
+
const clientEphPub = packet.subarray(1, 33);
|
|
172
|
+
const agentEphPub = this.ephemeral.publicKey;
|
|
173
|
+
const transcript = nacl.hash(concat(utf8(PROTOCOL + '\0'), utf8(this.nodeId), packet, agentEphPub));
|
|
174
|
+
const signature = nacl.sign.detached(transcript, this.identitySecretKey);
|
|
175
|
+
const shared = nacl.box.before(clientEphPub, this.ephemeral.secretKey);
|
|
176
|
+
const key = nacl.hash(concat(utf8(PROTOCOL + '-KDF\0'), shared, transcript)).subarray(0, 32); // SHA-512 取前 32 字节
|
|
177
|
+
const material = nacl.hash(concat(key, utf8('nonce-prefix')));
|
|
178
|
+
this.txPrefix = material.subarray(16, 32); // agent 发送前缀
|
|
179
|
+
this.rxPrefix = material.subarray(0, 16); // 客户端发送前缀
|
|
180
|
+
this.key = key;
|
|
181
|
+
this.ready = true;
|
|
182
|
+
return concat(new Uint8Array([PACKET_AGENT_HELLO]), agentEphPub, signature);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
seal(text) {
|
|
186
|
+
if (!this.ready) throw new Error('E2EE 会话未就绪');
|
|
187
|
+
const counter = this.txCounter;
|
|
188
|
+
this.txCounter += 1;
|
|
189
|
+
const clear = concat(new Uint8Array([CLEAR_DATA]), utf8(text));
|
|
190
|
+
const sealed = nacl.secretbox(clear, concat(this.txPrefix, counterBytes(counter)), this.key);
|
|
191
|
+
return concat(new Uint8Array([PACKET_SEALED]), counterBytes(counter), sealed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
open(packet) {
|
|
195
|
+
if (!this.ready || !packet || packet.length < 9 + 17 || packet[0] !== PACKET_SEALED) return null;
|
|
196
|
+
const counter = readCounter(packet, 1);
|
|
197
|
+
if (counter < this.rxCounter) return null;
|
|
198
|
+
const clear = nacl.secretbox.open(packet.subarray(9), concat(this.rxPrefix, counterBytes(counter)), this.key);
|
|
199
|
+
if (!clear || clear.length < 1 || clear[0] !== CLEAR_DATA) return null;
|
|
200
|
+
this.rxCounter = counter + 1;
|
|
201
|
+
return utf8Decode(clear.subarray(1));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------- 中继连接 ----------
|
|
206
|
+
|
|
207
|
+
let relaySocket = null;
|
|
208
|
+
let relayBackoff = 1000;
|
|
209
|
+
let relayWants = true;
|
|
210
|
+
|
|
211
|
+
function connectRelay() {
|
|
212
|
+
if (!relayWants) return;
|
|
213
|
+
log(`连接中继 ${RELAY}/a/${identity.nodeId}`);
|
|
214
|
+
const ws = new WebSocket(`${RELAY.replace(/\/$/, '')}/a/${identity.nodeId}`, { headers: { 'X-DSH-Gateway-Auth': '1' } });
|
|
215
|
+
relaySocket = ws;
|
|
216
|
+
|
|
217
|
+
ws.on('open', () => {
|
|
218
|
+
log('已注册到中继');
|
|
219
|
+
relayBackoff = 1000;
|
|
220
|
+
printPairingPayload();
|
|
221
|
+
// Preserve the plugin's QR generation: it needs this connector's identity.
|
|
222
|
+
fetch(MGMT_URL.replace(/\/$/, '') + '/mgw/relay', {
|
|
223
|
+
method: 'POST', signal: AbortSignal.timeout(5000),
|
|
224
|
+
headers: { 'Content-Type': 'application/json', Origin: new URL(MGMT_URL).origin },
|
|
225
|
+
body: JSON.stringify({ relay: RELAY.replace(/\/$/, ''), nodeId: identity.nodeId,
|
|
226
|
+
agentPubKey: identity.signPublicKey, gatewayName: GATEWAY_NAME })
|
|
227
|
+
}).then(response => { if (!response.ok) throw new Error('registration rejected'); })
|
|
228
|
+
.catch(() => log('插件身份注册未完成,请确认本机管理端口;既有配对信息不变'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ws.on('message', (data, isBinary) => {
|
|
232
|
+
if (isBinary) {
|
|
233
|
+
handleRelayBinary(data);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
let frame;
|
|
237
|
+
try { frame = JSON.parse(data.toString('utf8')); } catch (e) { return; }
|
|
238
|
+
if (frame.kind === 'pairing-offer') printPairingPayload(frame);
|
|
239
|
+
if (frame.kind === 'client-open') handleClientOpen(frame, ws).catch((error) => {
|
|
240
|
+
log(`建立客户端管道失败: ${error.message}`);
|
|
241
|
+
});
|
|
242
|
+
if (frame.kind === 'client-close') handleClientClose(frame);
|
|
243
|
+
if (frame.kind === 'code-created' || frame.kind === 'code-error' || frame.kind === 'code-redeemed') {
|
|
244
|
+
const pipe = pipes.get(frame.bridgeId);
|
|
245
|
+
if (pipe && pipe.session.ready) {
|
|
246
|
+
const kinds = {
|
|
247
|
+
'code-created': 'relay-code-created',
|
|
248
|
+
'code-error': 'relay-code-error',
|
|
249
|
+
'code-redeemed': 'relay-code-redeemed'
|
|
250
|
+
};
|
|
251
|
+
const response = Object.assign({ kind: kinds[frame.kind], requestId: frame.requestId }, frame);
|
|
252
|
+
delete response.kind_original;
|
|
253
|
+
response.kind = kinds[frame.kind];
|
|
254
|
+
sendPipeFrame(ws, frame.bridgeId, pipe.session.seal(JSON.stringify(response)));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (frame.kind === 'invite-created' || frame.kind === 'invite-error') {
|
|
258
|
+
const pipe = pipes.get(frame.bridgeId);
|
|
259
|
+
if (pipe && pipe.session.ready) {
|
|
260
|
+
const response = frame.kind === 'invite-created'
|
|
261
|
+
? { kind: 'relay-share-created', requestId: frame.requestId,
|
|
262
|
+
inviteToken: frame.inviteToken, expiresAt: frame.expiresAt }
|
|
263
|
+
: { kind: 'relay-share-error', requestId: frame.requestId, message: frame.message };
|
|
264
|
+
sendPipeFrame(ws, frame.bridgeId, pipe.session.seal(JSON.stringify(response)));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
ws.on('close', () => {
|
|
270
|
+
for (const [bridgeId, pipe] of pipes) if (pipe.agentWs === ws) handleClientClose({ bridgeId });
|
|
271
|
+
log('与中继断开,准备重连');
|
|
272
|
+
setTimeout(connectRelay, relayBackoff);
|
|
273
|
+
relayBackoff = Math.min(relayBackoff * 2, 30000);
|
|
274
|
+
});
|
|
275
|
+
ws.on('error', (err) => log('中继连接错误: ' + err.message));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------- 管道 ----------
|
|
279
|
+
|
|
280
|
+
const pipes = new Map(); // bridgeId → { session, gateway, agentWs }
|
|
281
|
+
|
|
282
|
+
function normalizeDeviceName(value) {
|
|
283
|
+
if (typeof value !== 'string' || /[\u0000-\u001f\u007f\ufffd]/.test(value)) return '';
|
|
284
|
+
return value.trim().slice(0, 80).replace(/[\uD800-\uDBFF]$/, '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Only a gateway-authenticated device id may be renamed. Never trust an id
|
|
288
|
+
// supplied by the phone or the public relay; the name arrives inside E2EE.
|
|
289
|
+
async function updateDeviceName(pipe, name) {
|
|
290
|
+
if (name !== undefined) pipe.phoneDeviceName = normalizeDeviceName(name);
|
|
291
|
+
if (!pipe.authenticatedDeviceId || !pipe.phoneDeviceName || pipe.renamingDevice
|
|
292
|
+
|| pipe.savedDeviceName === pipe.phoneDeviceName) return;
|
|
293
|
+
const targetName = pipe.phoneDeviceName;
|
|
294
|
+
pipe.renamingDevice = true;
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetch(MGMT_URL.replace(/\/$/, '') + '/mgw/devices/'
|
|
297
|
+
+ encodeURIComponent(pipe.authenticatedDeviceId) + '/rename', {
|
|
298
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify({ name: targetName })
|
|
300
|
+
});
|
|
301
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
302
|
+
pipe.savedDeviceName = targetName;
|
|
303
|
+
} catch (error) { log('设备名称更新失败: ' + error.message); }
|
|
304
|
+
finally { pipe.renamingDevice = false; }
|
|
305
|
+
if (pipe.phoneDeviceName !== targetName) void updateDeviceName(pipe);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function captureDeviceIdentity(pipe, text) {
|
|
309
|
+
let message; try { message = JSON.parse(text); } catch { return; }
|
|
310
|
+
if (message.kind !== 'hello' || message.authenticated !== true
|
|
311
|
+
|| !message.device || typeof message.device.id !== 'string') return;
|
|
312
|
+
pipe.authenticatedDeviceId = message.device.id;
|
|
313
|
+
pipe.savedDeviceName = message.device.name;
|
|
314
|
+
void updateDeviceName(pipe);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function requestPairingCode(deviceName) {
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetch(MGMT_URL.replace(/\/$/, '') + '/mgw/pair', {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'Content-Type': 'application/json' },
|
|
322
|
+
body: JSON.stringify({ name: deviceName || 'WeChat mini-program' })
|
|
323
|
+
});
|
|
324
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
325
|
+
const data = await response.json();
|
|
326
|
+
const pairingCode = data && data.payload && data.payload.pairingCode;
|
|
327
|
+
if (!pairingCode) throw new Error('配对码缺失');
|
|
328
|
+
return pairingCode;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
log('无法获取一次性配对码: ' + error.message);
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function handleClientOpen(frame, agentWs) {
|
|
336
|
+
const bridgeId = frame.bridgeId;
|
|
337
|
+
const session = new AgentSession(identity.nodeId, Buffer.from(identity.signSecretKey, 'base64'));
|
|
338
|
+
const protocols = Array.isArray(frame.protocols) ? frame.protocols.slice() : ['dsh-mobile-v1'];
|
|
339
|
+
const deviceId = String(frame.deviceId || crypto.randomUUID()).slice(0, 128);
|
|
340
|
+
const pipe = { session, gateway: null, agentWs, deviceId, gatewayReady: false, queue: [] };
|
|
341
|
+
// Register before any network await: the client may send ClientHello as
|
|
342
|
+
// soon as its WebSocket opens, before /mgw/pair responds.
|
|
343
|
+
pipes.set(bridgeId, pipe);
|
|
344
|
+
if (frame.authMode === 'gateway') {
|
|
345
|
+
// Wait for the phone's encrypted one-time pairing code or device token.
|
|
346
|
+
// A paid relay lease is not a device credential. Never auto-pair here.
|
|
347
|
+
pipe.requiresDeviceAuth = true;
|
|
348
|
+
pipe.authTimer = setTimeout(() => {
|
|
349
|
+
if (pipes.get(bridgeId) !== pipe || pipe.gatewayReady) return;
|
|
350
|
+
handleClientClose({ bridgeId });
|
|
351
|
+
if (agentWs.readyState === WebSocket.OPEN) agentWs.send(JSON.stringify({ kind: 'client-close', bridgeId }));
|
|
352
|
+
}, 15000);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const creds = loadCreds();
|
|
356
|
+
const token = creds[deviceId] || creds[GATEWAY];
|
|
357
|
+
const headers = {
|
|
358
|
+
'X-DSH-Channel': 'miniprogram',
|
|
359
|
+
'X-DSH-Device-ID': deviceId
|
|
360
|
+
};
|
|
361
|
+
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
362
|
+
else {
|
|
363
|
+
const pairingCode = await requestPairingCode(deviceId);
|
|
364
|
+
if (!pairingCode) {
|
|
365
|
+
pipes.delete(bridgeId);
|
|
366
|
+
agentWs.send(JSON.stringify({ kind: 'client-close', bridgeId }));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
protocols.push('dsh-pair.' + pairingCode);
|
|
370
|
+
}
|
|
371
|
+
// ws v8:子协议必须作为第二个位置参数,放 options.protocols 会被忽略
|
|
372
|
+
const gateway = new WebSocket(GATEWAY, protocols.filter(Boolean), { headers });
|
|
373
|
+
pipe.gateway = gateway;
|
|
374
|
+
log(`管道 ${bridgeId}: 拨本地网关 ${GATEWAY}`);
|
|
375
|
+
|
|
376
|
+
gateway.on('open', () => {
|
|
377
|
+
pipe.gatewayReady = true;
|
|
378
|
+
agentWs.send(JSON.stringify({ kind: 'client-ready', bridgeId }), { binary: false });
|
|
379
|
+
log(`管道 ${bridgeId}: 本地网关已连接,协议=${gateway.protocol || '(默认)'}`);
|
|
380
|
+
// 排队的已解密帧
|
|
381
|
+
for (const text of pipe.queue) gateway.send(text);
|
|
382
|
+
pipe.queue = [];
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
gateway.on('message', (data) => {
|
|
386
|
+
const text = data.toString('utf8');
|
|
387
|
+
captureToken(text, deviceId);
|
|
388
|
+
captureDeviceIdentity(pipe, text);
|
|
389
|
+
if (!session.ready) return;
|
|
390
|
+
try {
|
|
391
|
+
sendPipeFrame(agentWs, bridgeId, session.seal(text));
|
|
392
|
+
} catch (e) {
|
|
393
|
+
log(`管道 ${bridgeId}: 加密发送失败 ${e.message}`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
gateway.on('close', (code, reason) => {
|
|
398
|
+
log(`管道 ${bridgeId}: 本地网关关闭 ${code} ${reason.toString()}`);
|
|
399
|
+
pipes.delete(bridgeId);
|
|
400
|
+
if (agentWs.readyState === WebSocket.OPEN) {
|
|
401
|
+
agentWs.send(JSON.stringify({ kind: 'client-close', bridgeId }), { binary: false });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
gateway.on('error', (err) => log(`管道 ${bridgeId}: 网关错误 ${err.message}`));
|
|
405
|
+
|
|
406
|
+
// 等待 ClientHello:在中继 socket 的二进制消息里路由(见下方 handleRelayBinary)
|
|
407
|
+
pipe.pendingHello = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function sendPipeFrame(agentWs, bridgeId, payload) {
|
|
411
|
+
if (agentWs.readyState !== WebSocket.OPEN) return;
|
|
412
|
+
agentWs.send(Buffer.concat([Buffer.from(bridgeId + '\n', 'ascii'), Buffer.from(payload)]), { binary: true });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function handleClientClose(frame) {
|
|
416
|
+
const pipe = pipes.get(frame.bridgeId);
|
|
417
|
+
if (pipe) clearTimeout(pipe.authTimer);
|
|
418
|
+
if (pipe && pipe.gateway) {
|
|
419
|
+
try { pipe.gateway.terminate(); } catch (e) { /* 忽略 */ }
|
|
420
|
+
}
|
|
421
|
+
pipes.delete(frame.bridgeId);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function authenticateGateway(pipe, bridgeId, frame) {
|
|
425
|
+
if (pipe.authStarted || !frame || frame.type !== 'relay-auth') return;
|
|
426
|
+
pipe.authStarted = true;
|
|
427
|
+
const valid = value => typeof value === 'string' && /^[A-Za-z0-9_-]{16,512}$/.test(value);
|
|
428
|
+
if (!(valid(frame.token) !== valid(frame.pairingCode))) {
|
|
429
|
+
sendPipeFrame(pipe.agentWs, bridgeId, pipe.session.seal(JSON.stringify({ kind: 'relay-auth-error', message: '设备配对凭据无效,请重新扫码' })));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const protocols = ['dsh-mobile-v1'];
|
|
433
|
+
const headers = { 'X-DSH-Channel': 'miniprogram', 'X-DSH-Device-ID': pipe.deviceId };
|
|
434
|
+
const deviceName = normalizeDeviceName(frame.deviceName);
|
|
435
|
+
if (deviceName) {
|
|
436
|
+
pipe.phoneDeviceName = deviceName;
|
|
437
|
+
headers['X-DSH-Device-Name'] = Buffer.from(deviceName, 'utf8').toString('base64');
|
|
438
|
+
}
|
|
439
|
+
if (valid(frame.token)) headers.Authorization = 'Bearer ' + frame.token;
|
|
440
|
+
else protocols.push('dsh-pair.' + frame.pairingCode);
|
|
441
|
+
const gateway = pipe.gateway = new WebSocket(GATEWAY, protocols, { headers });
|
|
442
|
+
const pending = []; let pendingBytes = 0;
|
|
443
|
+
const rejectDevice = () => {
|
|
444
|
+
if (pipes.get(bridgeId) !== pipe) return;
|
|
445
|
+
sendPipeFrame(pipe.agentWs, bridgeId, pipe.session.seal(JSON.stringify({ kind: 'relay-auth-error', message: 'DSH 未确认设备认证,请重新扫码并检查网关认证设置' })));
|
|
446
|
+
handleClientClose({ bridgeId });
|
|
447
|
+
};
|
|
448
|
+
gateway.on('open', () => {
|
|
449
|
+
if (pipes.get(bridgeId) !== pipe) return gateway.terminate();
|
|
450
|
+
// A local debug gateway may accept WS even with an invalid token. Wait for
|
|
451
|
+
// its authenticated hello before exposing data or forwarding any commands.
|
|
452
|
+
});
|
|
453
|
+
gateway.on('message', data => {
|
|
454
|
+
if (pipes.get(bridgeId) !== pipe) return;
|
|
455
|
+
const text = data.toString('utf8');
|
|
456
|
+
if (!pipe.gatewayReady) {
|
|
457
|
+
let message; try { message = JSON.parse(text); } catch { return rejectDevice(); }
|
|
458
|
+
if (message.kind !== 'hello') {
|
|
459
|
+
// The gateway emits the new device token immediately before hello.
|
|
460
|
+
// Hold it until the same connection confirms successful authentication.
|
|
461
|
+
pendingBytes += data.length;
|
|
462
|
+
if (message.kind !== 'paired' || pendingBytes > 65536) return rejectDevice();
|
|
463
|
+
pending.push(text); return;
|
|
464
|
+
}
|
|
465
|
+
if (message.authenticated !== true) return rejectDevice();
|
|
466
|
+
captureDeviceIdentity(pipe, text);
|
|
467
|
+
pipe.gatewayReady = true; clearTimeout(pipe.authTimer);
|
|
468
|
+
pipe.agentWs.send(JSON.stringify({ kind: 'client-ready', bridgeId }));
|
|
469
|
+
for (const paired of pending) sendPipeFrame(pipe.agentWs, bridgeId, pipe.session.seal(paired));
|
|
470
|
+
pending.length = 0;
|
|
471
|
+
}
|
|
472
|
+
sendPipeFrame(pipe.agentWs, bridgeId, pipe.session.seal(text));
|
|
473
|
+
});
|
|
474
|
+
gateway.on('close', () => {
|
|
475
|
+
clearTimeout(pipe.authTimer); pipes.delete(bridgeId);
|
|
476
|
+
if (pipe.agentWs.readyState === WebSocket.OPEN) pipe.agentWs.send(JSON.stringify({ kind: 'client-close', bridgeId }));
|
|
477
|
+
});
|
|
478
|
+
gateway.on('error', () => {
|
|
479
|
+
if (pipe.agentWs.readyState === WebSocket.OPEN) sendPipeFrame(pipe.agentWs, bridgeId, pipe.session.seal(JSON.stringify({ kind: 'relay-auth-error', message: '设备配对失败,请在 DSH 重新生成二维码' })));
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Relay binary envelopes carry a bridge id so multiple phones can share a node.
|
|
484
|
+
function handleRelayBinary(data) {
|
|
485
|
+
const bytes = Buffer.from(data);
|
|
486
|
+
const splitAt = bytes.indexOf(10);
|
|
487
|
+
if (splitAt < 1 || splitAt > 64) return;
|
|
488
|
+
const bridgeId = bytes.subarray(0, splitAt).toString('ascii');
|
|
489
|
+
const packet = new Uint8Array(bytes.subarray(splitAt + 1));
|
|
490
|
+
const pipe = pipes.get(bridgeId);
|
|
491
|
+
if (!pipe) return;
|
|
492
|
+
if (packet.length && packet[0] === PACKET_CLIENT_HELLO && !pipe.session.ready) {
|
|
493
|
+
const hello = pipe.session.acceptClientHello(packet);
|
|
494
|
+
if (hello) {
|
|
495
|
+
sendPipeFrame(pipe.agentWs, bridgeId, hello);
|
|
496
|
+
log(`管道 ${bridgeId}: E2EE 握手完成`);
|
|
497
|
+
} else {
|
|
498
|
+
log(`管道 ${bridgeId}: ClientHello 无效`);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (pipe.session.ready && packet[0] === PACKET_SEALED) {
|
|
503
|
+
const text = pipe.session.open(packet);
|
|
504
|
+
if (text === null) {
|
|
505
|
+
log(`管道 ${bridgeId}: 密文解密失败,关闭管道`);
|
|
506
|
+
try { pipe.gateway.close(4002, 'e2ee-decrypt-failed'); } catch (error) { /* ignore */ }
|
|
507
|
+
pipes.delete(bridgeId);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
let frame;
|
|
511
|
+
try { frame = JSON.parse(text); } catch (error) { frame = null; }
|
|
512
|
+
if (frame && frame.type === 'device-info') {
|
|
513
|
+
// Compatibility with already-installed clients and legacy invite mode.
|
|
514
|
+
// Cache early reports until the gateway confirms this pipe's identity.
|
|
515
|
+
void updateDeviceName(pipe, frame.model);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (pipe.requiresDeviceAuth) {
|
|
519
|
+
if (!pipe.authStarted) { authenticateGateway(pipe, bridgeId, frame); return; }
|
|
520
|
+
if (frame && frame.type === 'relay-auth') return;
|
|
521
|
+
if (pipe.gatewayReady && pipe.gateway.readyState === WebSocket.OPEN) pipe.gateway.send(text);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (frame && frame.type === 'relay-share-create') {
|
|
525
|
+
pipe.agentWs.send(JSON.stringify({ kind: 'invite-create', bridgeId,
|
|
526
|
+
requestId: frame.requestId, ttlMs: frame.ttlMs }));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (frame && frame.type === 'relay-code-create') {
|
|
530
|
+
pipe.agentWs.send(JSON.stringify({ kind: 'code-create', bridgeId,
|
|
531
|
+
requestId: frame.requestId, accessTtlMs: frame.accessTtlMs,
|
|
532
|
+
uses: frame.uses, codeTtlMs: frame.codeTtlMs }));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (frame && frame.type === 'relay-code-redeem') {
|
|
536
|
+
pipe.agentWs.send(JSON.stringify({ kind: 'code-redeem', bridgeId,
|
|
537
|
+
requestId: frame.requestId, code: String(frame.code || '') }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
captureToken(text, pipe.deviceId);
|
|
541
|
+
if (pipe.gatewayReady && pipe.gateway.readyState === WebSocket.OPEN) pipe.gateway.send(text);
|
|
542
|
+
else pipe.queue.push(text);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------- 配对载荷 ----------
|
|
547
|
+
|
|
548
|
+
function pairingPayload(offer) {
|
|
549
|
+
const payload = {
|
|
550
|
+
version: 3,
|
|
551
|
+
mode: 'relay',
|
|
552
|
+
relay: RELAY.replace(/\/$/, ''),
|
|
553
|
+
nodeId: identity.nodeId,
|
|
554
|
+
agentPubKey: identity.signPublicKey,
|
|
555
|
+
gatewayName: GATEWAY_NAME,
|
|
556
|
+
gatewayUrl: GATEWAY,
|
|
557
|
+
inviteToken: offer.inviteToken,
|
|
558
|
+
expiresAt: offer.expiresAt,
|
|
559
|
+
role: 'owner'
|
|
560
|
+
};
|
|
561
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let printedAt = 0;
|
|
565
|
+
function printPairingPayload(offer) {
|
|
566
|
+
if (!offer || !offer.inviteToken || !(Number(offer.expiresAt) > Date.now())) return;
|
|
567
|
+
if (Date.now() - printedAt < 5000) return;
|
|
568
|
+
printedAt = Date.now();
|
|
569
|
+
console.log('\n===== 首次所有者配对信息(限时一次性使用)=====');
|
|
570
|
+
console.log(pairingPayload(offer));
|
|
571
|
+
console.log(`有效期至 ${new Date(offer.expiresAt).toLocaleString()}`);
|
|
572
|
+
console.log('=============================================\n');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function log(message) {
|
|
576
|
+
console.log(`[connector ${new Date().toISOString()}] ${message}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------- 主流程 ----------
|
|
580
|
+
|
|
581
|
+
process.on('SIGINT', () => {
|
|
582
|
+
relayWants = false;
|
|
583
|
+
log('退出');
|
|
584
|
+
process.exit(0);
|
|
585
|
+
});
|
|
586
|
+
process.on('SIGTERM', () => { relayWants = false; process.exit(0); });
|
|
587
|
+
|
|
588
|
+
connectRelay();
|