@sparkelf/dsh-plugin-mobile-gateway 0.9.0 → 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 }
@@ -59,7 +62,64 @@ function tokenHashes() {
59
62
  } catch { return new Set() }
60
63
  }
61
64
 
65
+ /**
66
+ * Write to a socket that may already be gone.
67
+ *
68
+ * `destroyed` is checked before the write but the peer can disappear between the check and the
69
+ * write, and a write to a closed socket emits an 'error' that, unhandled, ends the process — one
70
+ * phone hanging up took this proxy down (EPIPE from FrameReader.onFrame). Every write on a
71
+ * connection the peer controls goes through here.
72
+ *
73
+ * @param socket - the stream to write to; may be destroyed or undefined.
74
+ * @param payload - bytes or text to send.
75
+ * @returns whether the write was attempted.
76
+ */
77
+ function safeWrite(socket, payload) {
78
+ if (socket === undefined || socket === null || socket.destroyed === true || socket.writable === false) return false
79
+ try {
80
+ socket.write(payload)
81
+ return true
82
+ } catch {
83
+ try { socket.destroy() } catch {}
84
+ return false
85
+ }
86
+ }
87
+
62
88
  const frame = {
89
+ /**
90
+ * Wrap an already-serialized payload in one masked text frame.
91
+ *
92
+ * The upstream socket from an 'upgrade' event is a raw TCP stream, not a WebSocket object, so
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.
100
+ */
101
+ encodeClientText(text) {
102
+ const payload = Buffer.from(text, 'utf8')
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)
117
+ }
118
+ header[0] = 0x81
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])
122
+ },
63
123
  /** Encode one server frame. Payloads are text by construction. */
64
124
  encode(value) {
65
125
  const payload = Buffer.from(JSON.stringify(value), 'utf8')
@@ -146,6 +206,11 @@ function cachedEvents(sessionId) {
146
206
 
147
207
  // --- history shaping, mirroring the gateway so a phone cannot tell the difference ---
148
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
+ //
149
214
  // The gateway caps a page at 256 KB on its opening read because every byte travels the desktop's
150
215
  // upstream. Here the bytes are already local and the wire is the VPS's own 8.3 MB/s downstream, so
151
216
  // the cap only costs round trips: a 10866-event Session would take 118 pages, and at 173 ms RTT
@@ -225,7 +290,11 @@ function historyFrame(message, cached) {
225
290
  ...(message.view === 'conversation' ? { view: 'conversation' } : {}),
226
291
  ...(capped.hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
227
292
  cursor: lastSeq,
228
- 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,
229
298
  }
230
299
  }
231
300
 
@@ -269,7 +338,7 @@ server.on('upgrade', (request, socket, head) => {
269
338
  // asked for dsh-mobile-v1 must see it echoed or it will not treat the socket as established.
270
339
  const accept = response2.headers['sec-websocket-accept']
271
340
  const protocol = response2.headers['sec-websocket-protocol']
272
- socket.write(
341
+ safeWrite(socket,
273
342
  'HTTP/1.1 101 Switching Protocols\r\n'
274
343
  + 'Upgrade: websocket\r\n'
275
344
  + 'Connection: Upgrade\r\n'
@@ -277,11 +346,15 @@ server.on('upgrade', (request, socket, head) => {
277
346
  + (protocol === undefined ? '' : 'Sec-WebSocket-Protocol: ' + String(protocol) + '\r\n')
278
347
  + '\r\n',
279
348
  )
280
- if (head.length > 0) liveSocket.write(head)
281
- if (liveHead.length > 0) socket.write(liveHead)
349
+ // `head` holds leftover bytes the client sent after its handshake, already framed.
350
+ if (head.length > 0) safeWrite(liveSocket, head)
351
+ if (liveHead.length > 0) safeWrite(socket, liveHead)
282
352
 
283
353
  const liveReader = new FrameReader((text) => {
284
- if (!socket.destroyed) socket.write(frame.encode(JSON.parse(text)))
354
+ if (trace) console.log('trace upstream->client ' + text.slice(0, 200))
355
+ let parsed
356
+ try { parsed = JSON.parse(text) } catch { return }
357
+ safeWrite(socket, frame.encode(parsed))
285
358
  }, () => {})
286
359
  liveSocket.on('data', (chunk) => liveReader.push(chunk))
287
360
  liveSocket.on('close', () => { if (!socket.destroyed) socket.destroy() })
@@ -290,17 +363,20 @@ server.on('upgrade', (request, socket, head) => {
290
363
  // The client's frames are examined: a history request for a cached Session is answered from
291
364
  // local storage, and every other frame is forwarded unchanged.
292
365
  const clientReader = new FrameReader((text) => {
366
+ if (trace) console.log('trace client->upstream ' + text.slice(0, 200))
293
367
  let message
294
- try { message = JSON.parse(text) } catch { liveSocket.write(text); return }
368
+ try { message = JSON.parse(text) } catch { safeWrite(liveSocket, frame.encodeClientText(text)); return }
295
369
  if (message?.type === 'history' && typeof message.sessionId === 'string') {
296
370
  let cached
297
371
  try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
298
372
  if (cached !== undefined && cached.events.length > 0) {
299
- socket.write(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))
300
376
  return
301
377
  }
302
378
  }
303
- liveSocket.write(text)
379
+ safeWrite(liveSocket, frame.encodeClientText(text))
304
380
  }, (kind) => { if (kind === 'close') liveSocket.destroy() })
305
381
  socket.on('data', (chunk) => clientReader.push(chunk))
306
382
  })
@@ -313,20 +389,17 @@ server.on('upgrade', (request, socket, head) => {
313
389
  response2.on('end', () => {
314
390
  if (socket.destroyed) return
315
391
  const body = Buffer.concat(chunks)
316
- socket.write('HTTP/1.1 ' + String(response2.statusCode ?? 502) + ' ' + String(response2.statusMessage ?? 'Bad Gateway') + '\r\nContent-Length: ' + String(body.length) + '\r\nConnection: close\r\n\r\n')
317
- socket.end(body)
392
+ if (safeWrite(socket, 'HTTP/1.1 ' + String(response2.statusCode ?? 502) + ' ' + String(response2.statusMessage ?? 'Bad Gateway') + '\r\nContent-Length: ' + String(body.length) + '\r\nConnection: close\r\n\r\n')) socket.end(body)
318
393
  })
319
394
  })
320
395
  live.on('error', () => {
321
396
  if (socket.destroyed) return
322
- socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
323
- socket.end()
397
+ if (safeWrite(socket, 'HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')) socket.end()
324
398
  })
325
399
  live.setTimeout(20000, () => {
326
400
  live.destroy()
327
401
  if (!socket.destroyed) {
328
- socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')
329
- socket.end()
402
+ if (safeWrite(socket, 'HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')) socket.end()
330
403
  }
331
404
  })
332
405
  live.end()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.9.0",
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": [