@sparkelf/dsh-plugin-mobile-gateway 0.8.2 → 0.9.0

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,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Push Session snapshots to the entry VPS.
4
+ *
5
+ * A phone on the public path receives every byte through this desktop's own upstream, measured at
6
+ * 0.9 MB/s, while the entry VPS pushes downstream at 8.3 MB/s. Filling a cache there in the
7
+ * background moves that upstream cost off the phone's critical path: the phone then downloads from
8
+ * the VPS at its own speed and asks this gateway only for the increment.
9
+ *
10
+ * This walks the persisted Sessions, takes the ones worth caching, and PUTs each one's bytes. It
11
+ * never blocks a Session: a failed push is retried on the next pass, and nothing here is on the
12
+ * path a user waits for.
13
+ *
14
+ * Usage:
15
+ * push-session-cache.mjs --secret <value> [--home /root/.dsh] [--endpoint https://...]
16
+ * [--once] [--interval 300] [--max-sessions 20] [--rate 200]
17
+ */
18
+ import { execFileSync } from 'node:child_process'
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
20
+ import { join, resolve } from 'node:path'
21
+ import { request } from 'node:https'
22
+ import { createGunzip } from 'node:zlib'
23
+
24
+ const argv = process.argv.slice(2)
25
+ let secret = process.env.DSH_CACHE_SECRET ?? ''
26
+ let home = process.env.DSH_HOME ?? '/root/.dsh'
27
+ let endpoint = process.env.DSH_CACHE_ENDPOINT ?? 'https://dsh.tokensfree.eu.cc/cache'
28
+ let once = false
29
+ let intervalSeconds = 300
30
+ let maxSessions = 20
31
+ let rateBytesPerSecond = 200 * 1024
32
+ for (let index = 0; index < argv.length; index += 1) {
33
+ const arg = argv[index]
34
+ if (arg === '--secret') { secret = argv[++index]; continue }
35
+ if (arg === '--home') { home = resolve(argv[++index]); continue }
36
+ if (arg === '--endpoint') { endpoint = argv[++index]; continue }
37
+ if (arg === '--once') { once = true; continue }
38
+ if (arg === '--interval') { intervalSeconds = Number(argv[++index]); continue }
39
+ if (arg === '--max-sessions') { maxSessions = Number(argv[++index]); continue }
40
+ if (arg === '--rate') { rateBytesPerSecond = Number(argv[++index]) * 1024; continue }
41
+ if (arg === '--help' || arg === '-h') {
42
+ console.log('usage: push-session-cache.mjs --secret <value> [--home <dir>] [--endpoint <url>] [--once] [--interval 300] [--max-sessions 20] [--rate 200]')
43
+ process.exit(0)
44
+ }
45
+ throw new Error('unknown option: ' + arg)
46
+ }
47
+ if (secret === '') throw new Error('push-session-cache: --secret or DSH_CACHE_SECRET is required')
48
+
49
+ /** Sessions live one directory deep, under a project directory whose name encodes the cwd. */
50
+ function findSessions() {
51
+ const root = join(home, 'sessions')
52
+ if (!existsSync(root)) return []
53
+ const found = []
54
+ for (const project of readdirSync(root, { withFileTypes: true })) {
55
+ if (!project.isDirectory()) continue
56
+ const projectPath = join(root, project.name)
57
+ for (const session of readdirSync(projectPath, { withFileTypes: true })) {
58
+ if (!session.isDirectory()) continue
59
+ const directory = join(projectPath, session.name)
60
+ // Prefer the highest format present: a Session that predates the v4 migration keeps both.
61
+ const candidates = readdirSync(directory)
62
+ .map((name) => /^session\.v(\d+)\.jsonl\.zstd$/.exec(name))
63
+ .filter((match) => match !== null)
64
+ .map((match) => ({ name: match[0], version: Number(match[1]) }))
65
+ .sort((left, right) => right.version - left.version)
66
+ if (candidates.length === 0) continue
67
+ const path = join(directory, candidates[0].name)
68
+ found.push({ id: session.name, path, version: candidates[0].version, mtime: statSync(path).mtimeMs, bytes: statSync(path).size })
69
+ }
70
+ }
71
+ return found.sort((left, right) => right.mtime - left.mtime).slice(0, maxSessions)
72
+ }
73
+
74
+ /** Highest event seq in one compressed log, so the phone can tell cache from live. */
75
+ function lastSeq(path) {
76
+ const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
77
+ let last = 0
78
+ let events = 0
79
+ for (const line of raw.split('\n')) {
80
+ if (line === '') continue
81
+ try {
82
+ const row = JSON.parse(line)
83
+ if (typeof row.seq === 'number') { events += 1; if (row.seq > last) last = row.seq }
84
+ } catch { /* a row that does not parse carries no seq to track */ }
85
+ }
86
+ return { last, events }
87
+ }
88
+
89
+ /** PUT one file, paced so the push never saturates the link a user is waiting on. */
90
+ function push(session, meta, hashes) {
91
+ return new Promise((resolvePromise, rejectPromise) => {
92
+ const url = new URL(endpoint.replace(/\/$/, '') + '/' + encodeURIComponent(session.id))
93
+ url.searchParams.set('lastSeq', String(meta.last))
94
+ url.searchParams.set('events', String(meta.events))
95
+ for (const hash of hashes) url.searchParams.append('tokenHash', hash)
96
+ const body = readFileSync(session.path)
97
+ const call = request(url, {
98
+ method: 'PUT',
99
+ headers: {
100
+ 'content-type': 'application/octet-stream',
101
+ 'content-length': body.length,
102
+ 'x-dsh-cache-secret': secret,
103
+ },
104
+ }, (response) => {
105
+ const chunks = []
106
+ response.on('data', (chunk) => chunks.push(chunk))
107
+ response.on('end', () => {
108
+ if (response.statusCode === 200) resolvePromise(Buffer.concat(chunks).toString())
109
+ else rejectPromise(new Error('push failed: HTTP ' + String(response.statusCode) + ' ' + Buffer.concat(chunks).toString().slice(0, 200)))
110
+ })
111
+ })
112
+ call.on('error', rejectPromise)
113
+ call.end(body)
114
+ })
115
+ }
116
+
117
+ /**
118
+ * Credential hashes of the devices that may read, so the cache accepts exactly the paired set.
119
+ *
120
+ * The gateway stores a device's token as a hash and never keeps the plaintext, so this reads the
121
+ * hashes it does keep. A revoked device drops out on the next pass, because the set is rebuilt from
122
+ * the file rather than appended to.
123
+ */
124
+ function pairedTokenHashes() {
125
+ const file = join(home, 'mobile-gateway-devices.json')
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
128
+ const devices = Array.isArray(parsed?.devices) ? parsed.devices : []
129
+ return devices
130
+ .filter((device) => device?.revokedAt === null || device?.revokedAt === undefined)
131
+ .map((device) => device?.tokenHash)
132
+ .filter((hash) => typeof hash === 'string' && hash !== '')
133
+ } catch { return [] }
134
+ }
135
+
136
+ async function pass() {
137
+ const sessions = findSessions()
138
+ if (sessions.length === 0) { console.log('push-session-cache: no Sessions to cache'); return }
139
+ const hashes = pairedTokenHashes()
140
+ console.log('push-session-cache: ' + String(sessions.length) + ' Session(s), ' + String(hashes.length) + ' paired device(s)')
141
+ for (const session of sessions) {
142
+ try {
143
+ const meta = lastSeq(session.path)
144
+ const started = Date.now()
145
+ await push(session, meta, hashes)
146
+ const seconds = Math.max(0.001, (Date.now() - started) / 1000)
147
+ console.log(' cached ' + session.id.slice(0, 24) + ' ' + (session.bytes / 1048576).toFixed(1) + ' MB lastSeq=' + String(meta.last) + ' in ' + seconds.toFixed(1) + 's (' + String(Math.round(session.bytes / seconds)) + ' B/s)')
148
+ } catch (error) {
149
+ console.log(' FAILED ' + session.id.slice(0, 24) + ': ' + (error instanceof Error ? error.message : String(error)))
150
+ }
151
+ }
152
+ }
153
+
154
+ await pass()
155
+ if (!once) {
156
+ console.log('push-session-cache: every ' + String(intervalSeconds) + 's')
157
+ setInterval(() => { void pass() }, intervalSeconds * 1000)
158
+ }
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session-cache gateway proxy.
4
+ *
5
+ * A phone on the public path receives every byte through the desktop's own upstream, measured at
6
+ * 929 KB/s, while this host pushes downstream at 8.3 MB/s. Serving a Session's history from the
7
+ * cache here is therefore 191x faster and does not touch the desktop's link at all. Live traffic
8
+ * still needs the desktop, so this proxy splits the two: history answers from local storage, and
9
+ * everything else is forwarded to the desktop through the existing tunnel.
10
+ *
11
+ * It speaks the same wire protocol as the gateway, so a phone reaches it by connecting here
12
+ * instead — the protocol has no "fetch history elsewhere" field, and the App is a shipped binary
13
+ * this side cannot change. The cache therefore has to look like the gateway itself.
14
+ *
15
+ * Zero dependencies: the VPS carries Node without npm, so the WebSocket handshake and frame codec
16
+ * are implemented directly. Only text frames appear in this protocol, which keeps that tractable.
17
+ *
18
+ * Usage:
19
+ * session-cache-proxy.mjs [--port 7091] [--root /var/lib/dsh-mobile-cache]
20
+ * [--upstream http://127.0.0.1:3080] [--ws-path /ws/mobile]
21
+ */
22
+ import { createHash } from 'node:crypto'
23
+ import { execFileSync } from 'node:child_process'
24
+ import { existsSync, readFileSync, statSync } from 'node:fs'
25
+ import { createServer, request as httpRequest } from 'node:http'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ const argv = process.argv.slice(2)
29
+ let port = Number(process.env.DSH_PROXY_PORT ?? 7091)
30
+ let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
31
+ let upstream = process.env.DSH_UPSTREAM ?? 'http://127.0.0.1:3080'
32
+ // The live half travels the tunnel. On the entry host the tunnel's vhost answers by Host header,
33
+ // so the upstream address alone is not enough: it must also carry the public name.
34
+ let upstreamHost = process.env.DSH_UPSTREAM_HOST ?? ''
35
+ let wsPath = process.env.DSH_WS_PATH ?? '/ws/mobile'
36
+ let tokensFile = process.env.DSH_CACHE_TOKENS ?? ''
37
+ for (let index = 0; index < argv.length; index += 1) {
38
+ const arg = argv[index]
39
+ if (arg === '--port') { port = Number(argv[++index]); continue }
40
+ if (arg === '--root') { root = resolve(argv[++index]); continue }
41
+ if (arg === '--upstream') { upstream = argv[++index]; continue }
42
+ if (arg === '--upstream-host') { upstreamHost = argv[++index]; continue }
43
+ if (arg === '--ws-path') { wsPath = argv[++index]; continue }
44
+ if (arg === '--tokens') { tokensFile = resolve(argv[++index]); continue }
45
+ if (arg === '--help' || arg === '-h') {
46
+ console.log('usage: session-cache-proxy.mjs [--port 7091] [--root <dir>] [--upstream <url>] [--ws-path /ws/mobile] [--tokens <file>]')
47
+ process.exit(0)
48
+ }
49
+ throw new Error('unknown option: ' + arg)
50
+ }
51
+
52
+ /** Accepted device credential hashes; a read requires one of them. */
53
+ function tokenHashes() {
54
+ if (tokensFile === '') return new Set()
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(tokensFile, 'utf8'))
57
+ const list = Array.isArray(parsed) ? parsed : parsed?.hashes
58
+ return new Set((Array.isArray(list) ? list : []).filter((hash) => typeof hash === 'string'))
59
+ } catch { return new Set() }
60
+ }
61
+
62
+ const frame = {
63
+ /** Encode one server frame. Payloads are text by construction. */
64
+ encode(value) {
65
+ const payload = Buffer.from(JSON.stringify(value), 'utf8')
66
+ if (payload.length < 126) {
67
+ return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
68
+ }
69
+ if (payload.length < 65536) {
70
+ const header = Buffer.alloc(4)
71
+ header[0] = 0x81
72
+ header[1] = 126
73
+ header.writeUInt16BE(payload.length, 2)
74
+ return Buffer.concat([header, payload])
75
+ }
76
+ const header = Buffer.alloc(10)
77
+ header[0] = 0x81
78
+ header[1] = 127
79
+ header.writeBigUInt64BE(BigInt(payload.length), 2)
80
+ return Buffer.concat([header, payload])
81
+ },
82
+ }
83
+
84
+ /**
85
+ * Incremental text-frame decoder.
86
+ *
87
+ * Frames arrive fragmented across reads and batched within one, so the parser keeps a carry buffer
88
+ * and returns every complete frame it can. Control frames are answered rather than surfaced: the
89
+ * protocol uses none of them, but a client may still ping.
90
+ */
91
+ class FrameReader {
92
+ constructor(onFrame, onControl) {
93
+ this.carry = Buffer.alloc(0)
94
+ this.onFrame = onFrame
95
+ this.onControl = onControl
96
+ }
97
+ push(chunk) {
98
+ this.carry = Buffer.concat([this.carry, chunk])
99
+ for (;;) {
100
+ if (this.carry.length < 2) return
101
+ const first = this.carry[0]
102
+ const second = this.carry[1]
103
+ const opcode = first & 0x0f
104
+ const masked = (second & 0x80) !== 0
105
+ let length = second & 0x7f
106
+ let offset = 2
107
+ if (length === 126) {
108
+ if (this.carry.length < 4) return
109
+ length = this.carry.readUInt16BE(2)
110
+ offset = 4
111
+ } else if (length === 127) {
112
+ if (this.carry.length < 10) return
113
+ length = Number(this.carry.readBigUInt64BE(2))
114
+ offset = 10
115
+ }
116
+ const maskLength = masked ? 4 : 0
117
+ if (this.carry.length < offset + maskLength + length) return
118
+ let payload = this.carry.subarray(offset + maskLength, offset + maskLength + length)
119
+ if (masked) {
120
+ const mask = this.carry.subarray(offset, offset + 4)
121
+ payload = Buffer.from(payload)
122
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
123
+ }
124
+ this.carry = this.carry.subarray(offset + maskLength + length)
125
+ if (opcode === 0x8) { this.onControl('close', payload); return }
126
+ if (opcode === 0x9) { this.onControl('ping', payload); continue }
127
+ if (opcode === 0xa) { this.onControl('pong', payload); continue }
128
+ if (opcode === 0x1) this.onFrame(payload.toString('utf8'))
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Read one Session's cached events, decompressing the stored snapshot. */
134
+ function cachedEvents(sessionId) {
135
+ const path = join(root, sessionId + '.bin')
136
+ if (!existsSync(path)) return undefined
137
+ const meta = (() => { try { return JSON.parse(readFileSync(join(root, sessionId + '.meta.json'), 'utf8')) } catch { return undefined } })()
138
+ const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
139
+ const events = []
140
+ for (const line of raw.split('\n')) {
141
+ if (line === '') continue
142
+ try { events.push(JSON.parse(line)) } catch { /* a row that does not parse carries no event */ }
143
+ }
144
+ return { events: events.filter((event) => typeof event.seq === 'number'), meta }
145
+ }
146
+
147
+ // --- history shaping, mirroring the gateway so a phone cannot tell the difference ---
148
+ //
149
+ // The gateway caps a page at 256 KB on its opening read because every byte travels the desktop's
150
+ // upstream. Here the bytes are already local and the wire is the VPS's own 8.3 MB/s downstream, so
151
+ // the cap only costs round trips: a 10866-event Session would take 118 pages, and at 173 ms RTT
152
+ // that is 20 seconds of pure waiting before any data moves. The protocol's own per-frame ceiling is
153
+ // 4 MiB, which is what this uses.
154
+ const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024
155
+ const HISTORY_TOOL_RESULT_MAX_CHARS = 2000
156
+
157
+ function eventBytes(event) { return Buffer.byteLength(JSON.stringify(event), 'utf8') }
158
+
159
+ function trimConversationEvent(event) {
160
+ switch (event.type) {
161
+ case 'assistant/chunk':
162
+ case 'request/header':
163
+ case 'request/context':
164
+ case 'system/message':
165
+ return null
166
+ case 'assistant/message':
167
+ case 'assistant/attempt': {
168
+ const { stream, ...data } = event.data || {}
169
+ return { ...event, data }
170
+ }
171
+ case 'tool/result': {
172
+ const data = event.data || {}
173
+ const message = data.message
174
+ if (!message || !Array.isArray(message.content)) return event
175
+ let changed = false
176
+ const truncate = (block) => {
177
+ if (!block || typeof block !== 'object') return block
178
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
179
+ changed = true
180
+ return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
181
+ }
182
+ if (Array.isArray(block.content)) return { ...block, content: block.content.map(truncate) }
183
+ return block
184
+ }
185
+ const content = message.content.map(truncate)
186
+ return changed ? { ...event, data: { ...data, message: { ...message, content } } } : event
187
+ }
188
+ default:
189
+ return event
190
+ }
191
+ }
192
+
193
+ /** Keep the newest suffix within a byte budget; always keep the newest event. */
194
+ function capHistoryEvents(events, maxBytes, trim, beforeSeq) {
195
+ const bounded = beforeSeq === undefined ? events : events.filter((event) => event.seq < beforeSeq)
196
+ const processed = trim ? bounded.map(trimConversationEvent).filter(Boolean) : bounded
197
+ if (processed.length === 0) return { events: [], bytes: 0, hasMore: false }
198
+ let total = 0
199
+ let start = processed.length
200
+ for (let index = processed.length - 1; index >= 0; index -= 1) {
201
+ const size = eventBytes(processed[index])
202
+ if (start !== processed.length && total + size > maxBytes) break
203
+ total += size
204
+ start = index
205
+ }
206
+ return { events: processed.slice(start), bytes: total, hasMore: start > 0 }
207
+ }
208
+
209
+ /** Build the wire frame a phone expects, from cached events. */
210
+ function historyFrame(message, cached) {
211
+ // A larger page than the gateway would send: the bytes are local, so the only cost is the frame
212
+ // the protocol already permits.
213
+ const budget = Number.isSafeInteger(message.maxBytes) && message.maxBytes > 0
214
+ ? Math.min(message.maxBytes, HISTORY_DEFAULT_MAX_BYTES)
215
+ : HISTORY_DEFAULT_MAX_BYTES
216
+ const capped = capHistoryEvents(cached.events, budget, message.view === 'conversation', message.beforeSeq)
217
+ const oldest = capped.events[0]?.seq ?? cached.events[0]?.seq
218
+ const lastSeq = cached.events.length === 0 ? 0 : cached.events[cached.events.length - 1].seq
219
+ return {
220
+ kind: 'history',
221
+ sessionId: message.sessionId,
222
+ events: capped.events,
223
+ bytes: capped.bytes,
224
+ hasMore: capped.hasMore,
225
+ ...(message.view === 'conversation' ? { view: 'conversation' } : {}),
226
+ ...(capped.hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
227
+ cursor: lastSeq,
228
+ historyFormatVersion: 4,
229
+ }
230
+ }
231
+
232
+ const server = createServer((_request, response) => {
233
+ response.writeHead(404, { 'content-type': 'application/json' })
234
+ response.end(JSON.stringify({ error: 'websocket-only' }))
235
+ })
236
+
237
+ server.on('upgrade', (request, socket, head) => {
238
+ const url = new URL(request.url ?? '/', 'http://proxy.invalid')
239
+ if (url.pathname !== wsPath) { socket.destroy(); return }
240
+ const key = request.headers['sec-websocket-key']
241
+ if (typeof key !== 'string') { socket.destroy(); return }
242
+
243
+ // The upstream decides whether this connection is allowed: it owns device identity, and a gateway
244
+ // that refused would otherwise be reported to the phone as a successful upgrade of a socket that
245
+ // has no peer. So the handshake is opened upstream first and its outcome relayed verbatim.
246
+ const headers = {}
247
+ for (const name of ['authorization', 'sec-websocket-protocol', 'x-dsh-device-id', 'x-dsh-device-token', 'cookie', 'origin', 'user-agent']) {
248
+ const value = request.headers[name]
249
+ if (value !== undefined) headers[name] = value
250
+ }
251
+ const upstreamUrl = new URL(wsPath, upstream)
252
+ const live = httpRequest({
253
+ hostname: upstreamUrl.hostname,
254
+ port: upstreamUrl.port,
255
+ path: upstreamUrl.pathname,
256
+ method: 'GET',
257
+ headers: {
258
+ ...headers,
259
+ ...(upstreamHost === '' ? {} : { Host: upstreamHost }),
260
+ Connection: 'Upgrade',
261
+ Upgrade: 'websocket',
262
+ 'Sec-WebSocket-Key': key,
263
+ 'Sec-WebSocket-Version': '13',
264
+ },
265
+ })
266
+
267
+ live.on('upgrade', (response2, liveSocket, liveHead) => {
268
+ // Relay the upstream's own handshake, including the subprotocol it negotiated: a phone that
269
+ // asked for dsh-mobile-v1 must see it echoed or it will not treat the socket as established.
270
+ const accept = response2.headers['sec-websocket-accept']
271
+ const protocol = response2.headers['sec-websocket-protocol']
272
+ socket.write(
273
+ 'HTTP/1.1 101 Switching Protocols\r\n'
274
+ + 'Upgrade: websocket\r\n'
275
+ + 'Connection: Upgrade\r\n'
276
+ + 'Sec-WebSocket-Accept: ' + String(accept) + '\r\n'
277
+ + (protocol === undefined ? '' : 'Sec-WebSocket-Protocol: ' + String(protocol) + '\r\n')
278
+ + '\r\n',
279
+ )
280
+ if (head.length > 0) liveSocket.write(head)
281
+ if (liveHead.length > 0) socket.write(liveHead)
282
+
283
+ const liveReader = new FrameReader((text) => {
284
+ if (!socket.destroyed) socket.write(frame.encode(JSON.parse(text)))
285
+ }, () => {})
286
+ liveSocket.on('data', (chunk) => liveReader.push(chunk))
287
+ liveSocket.on('close', () => { if (!socket.destroyed) socket.destroy() })
288
+ liveSocket.on('error', () => { if (!socket.destroyed) socket.destroy() })
289
+
290
+ // The client's frames are examined: a history request for a cached Session is answered from
291
+ // local storage, and every other frame is forwarded unchanged.
292
+ const clientReader = new FrameReader((text) => {
293
+ let message
294
+ try { message = JSON.parse(text) } catch { liveSocket.write(text); return }
295
+ if (message?.type === 'history' && typeof message.sessionId === 'string') {
296
+ let cached
297
+ try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
298
+ if (cached !== undefined && cached.events.length > 0) {
299
+ socket.write(frame.encode(historyFrame(message, cached)))
300
+ return
301
+ }
302
+ }
303
+ liveSocket.write(text)
304
+ }, (kind) => { if (kind === 'close') liveSocket.destroy() })
305
+ socket.on('data', (chunk) => clientReader.push(chunk))
306
+ })
307
+
308
+ // A refusal is the upstream's answer, not the proxy's: pass its status and body through so the
309
+ // phone can act on it (401 means re-pair, and only the gateway may say that).
310
+ live.on('response', (response2) => {
311
+ const chunks = []
312
+ response2.on('data', (chunk) => chunks.push(chunk))
313
+ response2.on('end', () => {
314
+ if (socket.destroyed) return
315
+ 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)
318
+ })
319
+ })
320
+ live.on('error', () => {
321
+ if (socket.destroyed) return
322
+ socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
323
+ socket.end()
324
+ })
325
+ live.setTimeout(20000, () => {
326
+ live.destroy()
327
+ if (!socket.destroyed) {
328
+ socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')
329
+ socket.end()
330
+ }
331
+ })
332
+ live.end()
333
+ })
334
+
335
+ server.listen(port, '127.0.0.1', () => {
336
+ console.log('session-cache-proxy: listening on 127.0.0.1:' + String(port) + ' root=' + root + ' upstream=' + upstream + (upstreamHost === '' ? '' : ' host=' + upstreamHost))
337
+ })
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session cache for the mobile gateway.
4
+ *
5
+ * A phone on the public path receives every byte through the desktop's own upstream, measured at
6
+ * 0.9 MB/s on this deployment, while the entry VPS can push 8.3 MB/s downstream. Serving a Session
7
+ * from here therefore turns a minute of waiting into a second, and the desktop fills the cache in
8
+ * the background where its slow upstream costs the user nothing.
9
+ *
10
+ * The service is deliberately small and read-mostly: it stores one compressed snapshot per Session
11
+ * plus a cursor, and the phone asks for the difference afterwards through the gateway's own
12
+ * incremental history. It owns no Session semantics — it never parses event payloads, so a harness
13
+ * format change cannot corrupt it.
14
+ *
15
+ * Authentication reuses the gateway's device tokens: the desktop proves write access with a shared
16
+ * secret, and a phone proves read access with the same device id and token it paired with. A
17
+ * request without either is refused before any file is touched.
18
+ *
19
+ * Usage: session-cache.mjs [--port 7090] [--root /var/lib/dsh-mobile-cache] [--secret <value>]
20
+ */
21
+ import { createServer } from 'node:http'
22
+ import { createHash, timingSafeEqual } from 'node:crypto'
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
24
+ import { join, resolve } from 'node:path'
25
+
26
+ const argv = process.argv.slice(2)
27
+ let port = Number(process.env.DSH_CACHE_PORT ?? 7090)
28
+ let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
29
+ let secret = process.env.DSH_CACHE_SECRET ?? ''
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const arg = argv[index]
32
+ if (arg === '--port') { port = Number(argv[++index]); continue }
33
+ if (arg === '--root') { root = resolve(argv[++index]); continue }
34
+ if (arg === '--secret') { secret = argv[++index]; continue }
35
+ if (arg === '--help' || arg === '-h') {
36
+ console.log('usage: session-cache.mjs [--port 7090] [--root <dir>] [--secret <value>]')
37
+ process.exit(0)
38
+ }
39
+ throw new Error('unknown option: ' + arg)
40
+ }
41
+ if (secret === '') throw new Error('session-cache: --secret or DSH_CACHE_SECRET is required')
42
+
43
+ mkdirSync(root, { recursive: true })
44
+
45
+ /** Compare two secrets without leaking their length or content through timing. */
46
+ function secretMatches(provided) {
47
+ if (typeof provided !== 'string' || provided === '') return false
48
+ const a = createHash('sha256').update(provided).digest()
49
+ const b = createHash('sha256').update(secret).digest()
50
+ return timingSafeEqual(a, b)
51
+ }
52
+
53
+ /**
54
+ * Whether a read request carries a credential a paired device holds.
55
+ *
56
+ * The cache holds what the gateway already serves to a paired phone, so a read needs the same proof
57
+ * of pairing. The desktop never sees a device's plaintext token — the gateway stores only its hash —
58
+ * so the accepted set is hashes, and a presented token is hashed the same way to compare. A service
59
+ * with no declared hashes refuses every read rather than serving one anonymously.
60
+ */
61
+ let pairedTokenHashes = new Set()
62
+ function deviceTokenMatches(provided) {
63
+ if (typeof provided !== 'string' || provided === '') return false
64
+ return pairedTokenHashes.has(createHash('sha256').update(provided).digest('hex'))
65
+ }
66
+
67
+ /** Replace the accepted read credentials with the hashes the desktop declares. */
68
+ function setPairedTokenHashes(hashes) {
69
+ pairedTokenHashes = new Set(hashes.filter((hash) => typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash)))
70
+ }
71
+
72
+ /** Session ids are opaque; refuse anything that could escape the cache root. */
73
+ function cachePath(sessionId) {
74
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.length > 128) return undefined
75
+ return join(root, sessionId + '.bin')
76
+ }
77
+
78
+ function metaPath(sessionId) {
79
+ return join(root, sessionId + '.meta.json')
80
+ }
81
+
82
+ function readMeta(sessionId) {
83
+ try { return JSON.parse(readFileSync(metaPath(sessionId), 'utf8')) } catch { return undefined }
84
+ }
85
+
86
+ function writeMeta(sessionId, meta) {
87
+ const temporary = metaPath(sessionId) + '.tmp'
88
+ writeFileSync(temporary, JSON.stringify(meta))
89
+ renameSync(temporary, metaPath(sessionId))
90
+ }
91
+
92
+ function readBody(request, limit) {
93
+ return new Promise((resolvePromise, rejectPromise) => {
94
+ const chunks = []
95
+ let size = 0
96
+ request.on('data', (chunk) => {
97
+ size += chunk.length
98
+ if (size > limit) { rejectPromise(new Error('too large')); request.destroy(); return }
99
+ chunks.push(chunk)
100
+ })
101
+ request.on('end', () => resolvePromise(Buffer.concat(chunks)))
102
+ request.on('error', rejectPromise)
103
+ })
104
+ }
105
+
106
+ function send(response, status, value, headers = {}) {
107
+ const body = Buffer.from(JSON.stringify(value))
108
+ response.writeHead(status, { 'content-type': 'application/json', 'content-length': body.length, ...headers })
109
+ response.end(body)
110
+ }
111
+
112
+ const server = createServer(async (request, response) => {
113
+ const url = new URL(request.url ?? '/', 'http://cache.invalid')
114
+ const segments = url.pathname.split('/').filter((part) => part !== '')
115
+ try {
116
+ if (segments[0] !== 'cache') { send(response, 404, { error: 'not-found' }); return }
117
+
118
+ // Two distinct credentials, so a leaked phone token cannot rewrite the cache: writing takes
119
+ // the shared secret (the desktop side), reading takes the device credential the phone already
120
+ // paired with. The index is desktop-side bookkeeping and takes the secret as well — a phone
121
+ // asks for the one Session it is opening, never for a list of everything cached.
122
+ const desktopSide = request.method === 'PUT' || request.method === 'DELETE' || segments.length === 1
123
+ const deviceToken = request.headers['x-dsh-device-token']
124
+ const authorized = desktopSide
125
+ ? secretMatches(request.headers['x-dsh-cache-secret'])
126
+ : deviceTokenMatches(deviceToken)
127
+ if (!authorized) { send(response, 401, { error: 'unauthorized' }); return }
128
+
129
+ if (segments.length === 1) {
130
+ if (request.method !== 'GET') { send(response, 405, { error: 'method' }); return }
131
+ const entries = []
132
+ for (const name of readdirSync(root)) {
133
+ if (!name.endsWith('.meta.json')) continue
134
+ const sessionId = name.slice(0, -'.meta.json'.length)
135
+ const meta = readMeta(sessionId)
136
+ if (meta !== undefined) entries.push({ sessionId, ...meta })
137
+ }
138
+ send(response, 200, { entries })
139
+ return
140
+ }
141
+
142
+ const sessionId = segments[1]
143
+ const path = cachePath(sessionId)
144
+ if (path === undefined) { send(response, 400, { error: 'bad-session-id' }); return }
145
+
146
+ if (request.method === 'PUT') {
147
+ const body = await readBody(request, 64 * 1024 * 1024)
148
+ const temporary = path + '.tmp'
149
+ writeFileSync(temporary, body)
150
+ renameSync(temporary, path)
151
+ const lastSeq = Number(url.searchParams.get('lastSeq') ?? '0')
152
+ const eventCount = Number(url.searchParams.get('events') ?? '0')
153
+ // The desktop states which device credential hashes may read what it cached, so revocation
154
+ // is a property of the next write rather than a second channel.
155
+ const hashes = url.searchParams.getAll('tokenHash')
156
+ if (hashes.length > 0) setPairedTokenHashes(hashes)
157
+ writeMeta(sessionId, { bytes: body.length, lastSeq, eventCount, updatedAt: Date.now() })
158
+ send(response, 200, { stored: body.length, sessionId })
159
+ return
160
+ }
161
+
162
+ if (request.method === 'DELETE') {
163
+ rmSync(path, { force: true })
164
+ rmSync(metaPath(sessionId), { force: true })
165
+ send(response, 200, { removed: sessionId })
166
+ return
167
+ }
168
+
169
+ if (request.method === 'GET') {
170
+ if (!existsSync(path)) { send(response, 404, { error: 'not-cached' }); return }
171
+ const meta = readMeta(sessionId)
172
+ const stat = statSync(path)
173
+ response.writeHead(200, {
174
+ 'content-type': 'application/octet-stream',
175
+ 'content-length': stat.size,
176
+ 'x-dsh-cache-last-seq': String(meta?.lastSeq ?? 0),
177
+ 'x-dsh-cache-events': String(meta?.eventCount ?? 0),
178
+ })
179
+ response.end(readFileSync(path))
180
+ return
181
+ }
182
+
183
+ send(response, 405, { error: 'method' })
184
+ } catch (error) {
185
+ send(response, 500, { error: 'internal', message: error instanceof Error ? error.message : String(error) })
186
+ }
187
+ })
188
+
189
+ server.listen(port, '127.0.0.1', () => {
190
+ console.log('session-cache: listening on 127.0.0.1:' + String(port) + ' root=' + root)
191
+ })
package/lib/index.mjs CHANGED
@@ -2150,16 +2150,21 @@ const plugin = {
2150
2150
  }
2151
2151
  }
2152
2152
 
2153
- const advertisedEndpoints = (primary, extra = []) => {
2154
- const publicUrl = configuredPublicUrl()
2155
- return normalizeEndpoints([...new Set([
2156
- ...(primary ? [primary] : []),
2157
- ...extra,
2158
- ...configuredEndpoints,
2159
- ...(publicUrl ? [publicUrl] : []),
2160
- ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2161
- ])])
2162
- }
2153
+ // Order matters: the client tries these in turn, so the LAN entry leads. A phone on the same
2154
+ // network reaches it directly at 20 MB/s instead of routing every byte through the public
2155
+ // entry, whose upstream is the desktop's own — measured at 0.9 MB/s on this deployment, so a
2156
+ // public-first list made a same-room phone wait a minute for a Session the LAN could serve in
2157
+ // seconds. The public address stays advertised for when the phone leaves the network.
2158
+ const advertisedEndpoints = (primary, extra = []) => {
2159
+ const publicUrl = configuredPublicUrl()
2160
+ return normalizeEndpoints([...new Set([
2161
+ ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2162
+ ...extra,
2163
+ ...(primary ? [primary] : []),
2164
+ ...configuredEndpoints,
2165
+ ...(publicUrl ? [publicUrl] : []),
2166
+ ])])
2167
+ }
2163
2168
 
2164
2169
  const setGatewayMode = (mode, reason, persist = true) => {
2165
2170
  if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
@@ -2695,6 +2700,38 @@ const plugin = {
2695
2700
  const requestedChannel = req.headers['x-dsh-channel']
2696
2701
  ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2697
2702
  ws.deviceId = device && device.id
2703
+ // One malformed frame must not take the process down.
2704
+ //
2705
+ // `ws` parses frames inside the underlying socket's 'data' listener, and a protocol
2706
+ // violation (an unmasked frame, a reserved bit set) throws synchronously from there. A
2707
+ // listener that throws becomes an uncaughtException, so a single bad frame from any
2708
+ // client — checked for credentials or not — terminated the whole harness. Measured: six
2709
+ // such crashes in one day, each taking every other Session down with it.
2710
+ //
2711
+ // The parse runs one layer below the WebSocket object, so guarding the 'data' listener
2712
+ // is what actually contains it. The connection is closed; the process stays up.
2713
+ const rawSocket = ws._socket
2714
+ if (rawSocket !== undefined && rawSocket !== null) {
2715
+ for (const listener of rawSocket.listeners('data')) {
2716
+ rawSocket.off('data', listener)
2717
+ rawSocket.on('data', (chunk) => {
2718
+ try {
2719
+ listener.call(rawSocket, chunk)
2720
+ } catch (error) {
2721
+ log('client sent a malformed frame: ' + (error && error.code ? error.code : 'unknown') + '; closing the connection')
2722
+ clients.delete(ws)
2723
+ try { rawSocket.destroy() } catch {}
2724
+ try { ws.terminate() } catch {}
2725
+ }
2726
+ })
2727
+ }
2728
+ }
2729
+ ws.on('error', (error) => {
2730
+ log('client socket error: ' + (error && error.code ? error.code : 'unknown'))
2731
+ clients.delete(ws)
2732
+ try { ws.terminate() } catch {}
2733
+ })
2734
+ ws.on('close', () => { clients.delete(ws) })
2698
2735
  clients.add(ws)
2699
2736
  if (device) registry.connected(device.id)
2700
2737
  if (!connectedSinceEnabled) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.8.2",
4
- "description": "Mobile gateway for DSH 0.1.7 (Session format 4), carrying the fork's format adaptation: v3 rows are lifted on read so a Session written by an earlier release still opens. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
3
+ "version": "0.9.0",
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": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/session-cache.test.mjs && node test/session-cache-proxy.test.mjs && node test/frame-containment.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",