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