@sparkelf/dsh-plugin-mobile-gateway 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mobile gateway protocol harness.
4
+ *
5
+ * Drives a full pairing and Session load the way the App does, so a change can be checked without
6
+ * a phone in hand. It speaks the wire protocol directly: the App is a shipped binary and the point
7
+ * is to reproduce its behaviour, not the gateway's own tests.
8
+ *
9
+ * Usage:
10
+ * protocol-harness.mjs --host 127.0.0.1 --port 7091 --code <pairingCode> [--json]
11
+ * [--expect-history] [--timeout 30000]
12
+ *
13
+ * Exit codes: 0 the flow completed, 1 a protocol failure, 2 the flow timed out.
14
+ */
15
+ import net from 'node:net'
16
+ import { randomBytes } from 'node:crypto'
17
+
18
+ const argv = process.argv.slice(2)
19
+ let host = '127.0.0.1'
20
+ let port = 7091
21
+ let code = ''
22
+ let json = false
23
+ let expectHistory = false
24
+ let sessionId = ''
25
+ let timeoutMs = 30000
26
+ let path = '/ws/mobile'
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const arg = argv[index]
29
+ if (arg === '--host') { host = argv[++index]; continue }
30
+ if (arg === '--port') { port = Number(argv[++index]); continue }
31
+ if (arg === '--code') { code = argv[++index]; continue }
32
+ if (arg === '--path') { path = argv[++index]; continue }
33
+ if (arg === '--timeout') { timeoutMs = Number(argv[++index]); continue }
34
+ if (arg === '--json') { json = true; continue }
35
+ if (arg === '--expect-history') { expectHistory = true; continue }
36
+ if (arg === '--session') { sessionId = argv[++index]; continue }
37
+ if (arg === '--help' || arg === '-h') {
38
+ console.log('usage: protocol-harness.mjs --code <pairingCode> [--host 127.0.0.1] [--port 7091] [--path /ws/mobile] [--json] [--expect-history] [--timeout 30000]')
39
+ process.exit(0)
40
+ }
41
+ throw new Error('unknown option: ' + arg)
42
+ }
43
+
44
+ const report = { steps: [], frames: [], errors: [], ok: false }
45
+
46
+ function note(step) { report.steps.push(step); if (!json) console.log(' ' + step) }
47
+ function fail(message) { report.errors.push(message); if (!json) console.log(' FAIL ' + message) }
48
+
49
+ /** One masked client text frame, per RFC 6455 — an unmasked frame is rejected as EXPECTED_MASK. */
50
+ function encodeClientText(text) {
51
+ const payload = Buffer.from(text, 'utf8')
52
+ const mask = randomBytes(4)
53
+ const length = payload.length
54
+ let header
55
+ if (length < 126) {
56
+ header = Buffer.alloc(2)
57
+ header[1] = 0x80 | length
58
+ } else if (length < 65536) {
59
+ header = Buffer.alloc(4)
60
+ header[1] = 0x80 | 126
61
+ header.writeUInt16BE(length, 2)
62
+ } else {
63
+ header = Buffer.alloc(10)
64
+ header[1] = 0x80 | 127
65
+ header.writeBigUInt64BE(BigInt(length), 2)
66
+ }
67
+ header[0] = 0x81
68
+ const masked = Buffer.from(payload)
69
+ for (let index = 0; index < masked.length; index += 1) masked[index] ^= mask[index % 4]
70
+ return Buffer.concat([header, mask, masked])
71
+ }
72
+
73
+ const socket = net.connect(port, host)
74
+ let carry = Buffer.alloc(0)
75
+ let handshakeDone = false
76
+ let finished = false
77
+ const sentHistory = new Set()
78
+
79
+ function finish(ok, why) {
80
+ if (finished) return
81
+ finished = true
82
+ report.ok = ok
83
+ if (why !== undefined) fail(why)
84
+ if (json) console.log(JSON.stringify(report, null, 2))
85
+ else {
86
+ console.log()
87
+ console.log(' 帧序列: ' + report.frames.map((frame) => frame.kind).join(' -> '))
88
+ console.log(' 结果: ' + (ok ? 'PASS' : 'FAIL'))
89
+ if (report.errors.length > 0) for (const error of report.errors) console.log(' - ' + error)
90
+ }
91
+ try { socket.destroy() } catch {}
92
+ process.exit(ok ? 0 : 1)
93
+ }
94
+
95
+ /** Decode server frames; the server never masks, so the payload follows the header directly. */
96
+ function readFrames() {
97
+ for (;;) {
98
+ if (carry.length < 2) return
99
+ const opcode = carry[0] & 0x0f
100
+ const masked = (carry[1] & 0x80) !== 0
101
+ let length = carry[1] & 0x7f
102
+ let offset = 2
103
+ if (length === 126) { if (carry.length < 4) return; length = carry.readUInt16BE(2); offset = 4 }
104
+ else if (length === 127) { if (carry.length < 10) return; length = Number(carry.readBigUInt64BE(2)); offset = 10 }
105
+ const maskLength = masked ? 4 : 0
106
+ if (carry.length < offset + maskLength + length) return
107
+ let payload = carry.subarray(offset + maskLength, offset + maskLength + length)
108
+ if (masked) {
109
+ const mask = carry.subarray(offset, offset + 4)
110
+ payload = Buffer.from(payload)
111
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
112
+ }
113
+ carry = carry.subarray(offset + maskLength + length)
114
+ if (opcode === 0x8) { note('server closed the connection'); finish(false, 'the server closed before the flow completed'); return }
115
+ if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, 0x80]), randomBytes(4)])); continue }
116
+ if (opcode !== 0x1) continue
117
+ onFrame(payload.toString('utf8'))
118
+ }
119
+ }
120
+
121
+ function onFrame(text) {
122
+ let frame
123
+ try { frame = JSON.parse(text) } catch { fail('the server sent a frame that is not JSON'); return }
124
+ report.frames.push({ kind: frame.kind, bytes: text.length })
125
+ if (!json) console.log(' <- ' + frame.kind + (frame.kind === 'history' ? ' events=' + String(frame.events?.length ?? 0) + ' cursor=' + String(frame.cursor) + ' bytes=' + String(frame.bytes) : ''))
126
+ if (frame.kind === 'error') { fail('server error frame: ' + JSON.stringify(frame).slice(0, 160)); return }
127
+ if (frame.kind === 'paired') { note('paired'); socket.write(encodeClientText(JSON.stringify({ type: 'sessions' }))); return }
128
+ if (frame.kind === 'sessions') {
129
+ note('sessions: ' + String(frame.sessions?.length ?? 0))
130
+ // A named Session lets a caller exercise a Session known to hold events, which the default
131
+ // first-two choice cannot guarantee.
132
+ const ids = sessionId !== '' ? [sessionId] : (frame.sessions ?? []).map((session) => session.sessionId).slice(0, 2)
133
+ for (const id of ids) { sentHistory.add(id); socket.write(encodeClientText(JSON.stringify({ type: 'history', sessionId: id, view: 'conversation' }))) }
134
+ if (ids.length === 0) { note('no Sessions to request'); finish(true) }
135
+ return
136
+ }
137
+ if (frame.kind === 'history') {
138
+ note('history for ' + String(frame.sessionId).slice(0, 24) + ': ' + String(frame.events?.length ?? 0) + ' events'
139
+ + ' cursor=' + String(frame.cursor) + ' hasMore=' + String(frame.hasMore)
140
+ + ' bytes=' + String(frame.bytes) + ' formatVersion=' + String(frame.historyFormatVersion)
141
+ + ' view=' + String(frame.view))
142
+ if ((frame.events?.length ?? 0) > 0) {
143
+ const sequenced = frame.events.every((event) => typeof event.seq === 'number')
144
+ if (!sequenced) fail('history carried an event without a seq')
145
+ }
146
+ const done = [...sentHistory].every((id) => report.frames.some((seen) => seen.kind === 'history'))
147
+ if (done) finish(report.errors.length === 0)
148
+ }
149
+ }
150
+
151
+ socket.on('connect', () => {
152
+ const key = randomBytes(16).toString('base64')
153
+ const protocols = code === '' ? 'dsh-mobile-v1' : 'dsh-mobile-v1, dsh-pair.' + code
154
+ socket.write(
155
+ 'GET ' + path + ' HTTP/1.1\r\n'
156
+ + 'Host: ' + host + ':' + String(port) + '\r\n'
157
+ + 'Upgrade: websocket\r\n'
158
+ + 'Connection: Upgrade\r\n'
159
+ + 'Sec-WebSocket-Version: 13\r\n'
160
+ + 'Sec-WebSocket-Key: ' + key + '\r\n'
161
+ + 'Sec-WebSocket-Protocol: ' + protocols + '\r\n'
162
+ + 'X-DSH-Device-ID: harness-' + randomBytes(8).toString('hex') + '\r\n'
163
+ + '\r\n',
164
+ )
165
+ })
166
+
167
+ socket.on('data', (chunk) => {
168
+ if (!handshakeDone) {
169
+ carry = Buffer.concat([carry, chunk])
170
+ const end = carry.indexOf('\r\n\r\n')
171
+ if (end < 0) return
172
+ const head = carry.subarray(0, end).toString('utf8')
173
+ const status = head.split('\r\n')[0]
174
+ report.handshake = status
175
+ if (!json) console.log(' handshake: ' + status)
176
+ if (!head.startsWith('HTTP/1.1 101')) { finish(false, 'handshake refused: ' + status); return }
177
+ handshakeDone = true
178
+ note('upgraded')
179
+ carry = carry.subarray(end + 4)
180
+ } else {
181
+ carry = Buffer.concat([carry, chunk])
182
+ }
183
+ readFrames()
184
+ })
185
+
186
+ socket.on('error', (error) => finish(false, 'socket error: ' + error.message))
187
+ setTimeout(() => finish(false, 'timed out after ' + String(timeoutMs) + 'ms'), timeoutMs)
@@ -19,7 +19,7 @@
19
19
  * session-cache-proxy.mjs [--port 7091] [--root /var/lib/dsh-mobile-cache]
20
20
  * [--upstream http://127.0.0.1:3080] [--ws-path /ws/mobile]
21
21
  */
22
- import { createHash } from 'node:crypto'
22
+ import { createHash, randomBytes } from 'node:crypto'
23
23
  import { execFileSync } from 'node:child_process'
24
24
  import { existsSync, readFileSync, statSync } from 'node:fs'
25
25
  import { createServer, request as httpRequest } from 'node:http'
@@ -34,6 +34,9 @@ let upstream = process.env.DSH_UPSTREAM ?? 'http://127.0.0.1:3080'
34
34
  let upstreamHost = process.env.DSH_UPSTREAM_HOST ?? ''
35
35
  let wsPath = process.env.DSH_WS_PATH ?? '/ws/mobile'
36
36
  let tokensFile = process.env.DSH_CACHE_TOKENS ?? ''
37
+ // When set, every frame's kind and size is logged. Used to see what a client actually asked for and
38
+ // what it was given, which is the only way to tell a client that is waiting from one that is retrying.
39
+ const trace = process.env.DSH_PROXY_TRACE === '1'
37
40
  for (let index = 0; index < argv.length; index += 1) {
38
41
  const arg = argv[index]
39
42
  if (arg === '--port') { port = Number(argv[++index]); continue }
@@ -84,31 +87,38 @@ function safeWrite(socket, payload) {
84
87
 
85
88
  const frame = {
86
89
  /**
87
- * Wrap an already-serialized payload in one unmasked text frame.
90
+ * Wrap an already-serialized payload in one masked text frame.
88
91
  *
89
92
  * The upstream socket from an 'upgrade' event is a raw TCP stream, not a WebSocket object, so
90
- * anything written to it must already be framed. Writing the bare JSON text instead is a silent
91
- * corruption: the payload's first byte becomes the frame header, and '{' is 0x7B = FIN 0, RSV1 1,
92
- * RSV2 1, RSV3 1, opcode 0xB — which the receiving parser rejects as "RSV2 and RSV3 must be
93
- * clear" and closes the connection over.
93
+ * anything written to it must already be framed. Bare JSON corrupts the header instead: the first
94
+ * payload byte reads as the frame header, and '{' is 0x7B = FIN 0, RSV1 1, RSV2 1, RSV3 1,
95
+ * opcode 0xB, which the receiver rejects as "RSV2 and RSV3 must be clear".
96
+ *
97
+ * It must also be *masked*. In this direction the proxy is the client, and RFC 6455 requires
98
+ * client-to-server frames to carry a mask — a server rejects an unmasked one with
99
+ * WS_ERR_EXPECTED_MASK and closes the connection, which the phone sees as a failed login.
94
100
  */
95
- encodeText(text) {
101
+ encodeClientText(text) {
96
102
  const payload = Buffer.from(text, 'utf8')
97
- if (payload.length < 126) {
98
- return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
103
+ const mask = randomBytes(4)
104
+ const length = payload.length
105
+ let header
106
+ if (length < 126) {
107
+ header = Buffer.alloc(2)
108
+ header[1] = 0x80 | length
109
+ } else if (length < 65536) {
110
+ header = Buffer.alloc(4)
111
+ header[1] = 0x80 | 126
112
+ header.writeUInt16BE(length, 2)
113
+ } else {
114
+ header = Buffer.alloc(10)
115
+ header[1] = 0x80 | 127
116
+ header.writeBigUInt64BE(BigInt(length), 2)
99
117
  }
100
- if (payload.length < 65536) {
101
- const header = Buffer.alloc(4)
102
- header[0] = 0x81
103
- header[1] = 126
104
- header.writeUInt16BE(payload.length, 2)
105
- return Buffer.concat([header, payload])
106
- }
107
- const header = Buffer.alloc(10)
108
118
  header[0] = 0x81
109
- header[1] = 127
110
- header.writeBigUInt64BE(BigInt(payload.length), 2)
111
- return Buffer.concat([header, payload])
119
+ const masked = Buffer.from(payload)
120
+ for (let index = 0; index < masked.length; index += 1) masked[index] ^= mask[index % 4]
121
+ return Buffer.concat([header, mask, masked])
112
122
  },
113
123
  /** Encode one server frame. Payloads are text by construction. */
114
124
  encode(value) {
@@ -196,6 +206,11 @@ function cachedEvents(sessionId) {
196
206
 
197
207
  // --- history shaping, mirroring the gateway so a phone cannot tell the difference ---
198
208
  //
209
+ // The gateway announces its Session format in the hello frame and the client checks the history
210
+ // answer against it. This must match that announcement, not the newest format the cache happens to
211
+ // hold: the cache stores whatever the desktop wrote, while the handshake is what the client trusts.
212
+ const GATEWAY_HISTORY_FORMAT_VERSION = 3
213
+ //
199
214
  // The gateway caps a page at 256 KB on its opening read because every byte travels the desktop's
200
215
  // upstream. Here the bytes are already local and the wire is the VPS's own 8.3 MB/s downstream, so
201
216
  // the cap only costs round trips: a 10866-event Session would take 118 pages, and at 173 ms RTT
@@ -275,7 +290,11 @@ function historyFrame(message, cached) {
275
290
  ...(message.view === 'conversation' ? { view: 'conversation' } : {}),
276
291
  ...(capped.hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
277
292
  cursor: lastSeq,
278
- historyFormatVersion: 4,
293
+ // Must equal what the gateway announced in its own hello frame, which is its
294
+ // SESSION_FORMAT_VERSION. The client validates the version on the history answer against the one
295
+ // it was told, so announcing 4 while the gateway says 3 makes it reject every page and stay on
296
+ // "loading history". The wire can carry either; the handshake is what must agree.
297
+ historyFormatVersion: GATEWAY_HISTORY_FORMAT_VERSION,
279
298
  }
280
299
  }
281
300
 
@@ -332,6 +351,7 @@ server.on('upgrade', (request, socket, head) => {
332
351
  if (liveHead.length > 0) safeWrite(socket, liveHead)
333
352
 
334
353
  const liveReader = new FrameReader((text) => {
354
+ if (trace) console.log('trace upstream->client ' + text.slice(0, 200))
335
355
  let parsed
336
356
  try { parsed = JSON.parse(text) } catch { return }
337
357
  safeWrite(socket, frame.encode(parsed))
@@ -343,17 +363,20 @@ server.on('upgrade', (request, socket, head) => {
343
363
  // The client's frames are examined: a history request for a cached Session is answered from
344
364
  // local storage, and every other frame is forwarded unchanged.
345
365
  const clientReader = new FrameReader((text) => {
366
+ if (trace) console.log('trace client->upstream ' + text.slice(0, 200))
346
367
  let message
347
- try { message = JSON.parse(text) } catch { safeWrite(liveSocket, frame.encodeText(text)); return }
368
+ try { message = JSON.parse(text) } catch { safeWrite(liveSocket, frame.encodeClientText(text)); return }
348
369
  if (message?.type === 'history' && typeof message.sessionId === 'string') {
349
370
  let cached
350
371
  try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
351
372
  if (cached !== undefined && cached.events.length > 0) {
352
- safeWrite(socket, frame.encode(historyFrame(message, cached)))
373
+ const answer = historyFrame(message, cached)
374
+ if (trace) console.log('trace cache->client history sessionId=' + message.sessionId + ' events=' + String(answer.events.length) + ' bytes=' + String(answer.bytes))
375
+ safeWrite(socket, frame.encode(answer))
353
376
  return
354
377
  }
355
378
  }
356
- safeWrite(liveSocket, frame.encodeText(text))
379
+ safeWrite(liveSocket, frame.encodeClientText(text))
357
380
  }, (kind) => { if (kind === 'close') liveSocket.destroy() })
358
381
  socket.on('data', (chunk) => clientReader.push(chunk))
359
382
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Mobile gateway for DSH 0.1.7 (Session format 4) with an entry-VPS Session cache. Reads DSH 0.1.7 Session format 4 and lifts retired v3 message shapes on read; a companion cache service plus gateway proxy let a phone fetch history from the entry host at 109 MB/s instead of through the desktop's 0.9 MB/s upstream. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [