@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.
- package/bin/protocol-harness.mjs +187 -0
- package/bin/session-cache-proxy.mjs +47 -24
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
101
|
+
encodeClientText(text) {
|
|
96
102
|
const payload = Buffer.from(text, 'utf8')
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
return Buffer.concat([header,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": [
|