dsh-multi-chat 0.6.7 → 1.0.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.
Files changed (63) hide show
  1. package/README.md +20 -21
  2. package/README.zh.md +20 -21
  3. package/bin/dsh-multi-chat.mjs +5 -9
  4. package/lib/client.js +778 -0
  5. package/lib/client.js.map +1 -0
  6. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/index.js +57 -69
  7. package/lib/tsconfig.tsbuildinfo +1 -0
  8. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallToggle.d.ts +1 -1
  9. package/lib/types/client/WallToggle.d.ts.map +1 -0
  10. package/lib/types/client/WallToggle.js +36 -0
  11. package/lib/types/client/WallToggle.js.map +1 -0
  12. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallView.d.ts +1 -1
  13. package/lib/types/client/WallView.d.ts.map +1 -0
  14. package/lib/types/client/WallView.js +218 -0
  15. package/lib/types/client/WallView.js.map +1 -0
  16. package/lib/types/client/index.d.ts.map +1 -0
  17. package/lib/types/client/index.js +59 -0
  18. package/lib/types/client/index.js.map +1 -0
  19. package/lib/types/client/locales.d.ts.map +1 -0
  20. package/lib/types/client/locales.js +96 -0
  21. package/lib/types/client/locales.js.map +1 -0
  22. package/lib/types/client/store.d.ts.map +1 -0
  23. package/lib/types/client/store.js +27 -0
  24. package/lib/types/client/store.js.map +1 -0
  25. package/lib/types/client/wall-injected.d.ts.map +1 -0
  26. package/lib/types/client/wall-injected.js +76 -0
  27. package/lib/types/client/wall-injected.js.map +1 -0
  28. package/lib/types/gateway.d.ts +57 -0
  29. package/lib/types/gateway.d.ts.map +1 -0
  30. package/lib/types/gateway.js +566 -0
  31. package/lib/types/gateway.js.map +1 -0
  32. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/index.d.ts +1 -16
  33. package/lib/types/index.d.ts.map +1 -0
  34. package/lib/types/index.js +550 -0
  35. package/lib/types/index.js.map +1 -0
  36. package/lib/types/invariant.d.ts.map +1 -0
  37. package/lib/types/invariant.js +26 -0
  38. package/lib/types/invariant.js.map +1 -0
  39. package/package.json +31 -17
  40. package/scripts/install-plugin.ps1 +6 -9
  41. package/src/client/WallToggle.module.css +26 -0
  42. package/src/client/WallToggle.tsx +52 -0
  43. package/src/client/WallView.module.css +190 -0
  44. package/src/client/WallView.tsx +406 -0
  45. package/src/client/index.ts +103 -0
  46. package/src/client/locales.ts +100 -0
  47. package/src/client/store.ts +40 -0
  48. package/src/client/wall-injected.ts +123 -0
  49. package/src/css-modules.d.ts +5 -0
  50. package/src/gateway.ts +627 -0
  51. package/src/index.ts +618 -0
  52. package/src/invariant.ts +34 -0
  53. package/plugin/dsh-client-ui-multi-wall/README.md +0 -26
  54. package/plugin/dsh-client-ui-multi-wall/README.zh.md +0 -26
  55. package/plugin/dsh-client-ui-multi-wall/cordis.patch.yml +0 -7
  56. package/plugin/dsh-client-ui-multi-wall/lib/client.js +0 -781
  57. package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +0 -27
  58. package/plugin/dsh-client-ui-multi-wall/package.json +0 -88
  59. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/index.d.ts +0 -0
  60. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/locales.d.ts +0 -0
  61. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/store.d.ts +0 -0
  62. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/wall-injected.d.ts +0 -0
  63. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/invariant.d.ts +0 -0
package/src/gateway.ts ADDED
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Inline authenticated gateway for the multi-wall plugin's node half.
3
+ *
4
+ * The official `dsh web` CLI forbids `--host 0.0.0.0` (it would expose remote
5
+ * code execution to the network), so reaching a loopback-only DSH instance
6
+ * from a phone/another machine needs an auth-gated gateway in front of it.
7
+ *
8
+ * This module embeds that gateway directly in the plugin (no external
9
+ * `gateway.mjs` file needed at runtime): a raw TCP listener that authenticates
10
+ * a token (HMAC-signed session cookie), then transparently proxies to
11
+ * `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
12
+ * browser-trust fence treats it as a local request. WebSocket upgrades pass
13
+ * through untouched because the socket is piped byte-for-byte after the head.
14
+ * The HTML document response is buffered once so a `crypto.randomUUID`
15
+ * polyfill can be injected for phones (insecure-origin HTTP); all other
16
+ * responses stream through unchanged.
17
+ *
18
+ * Zero dependencies: node:net + node:crypto only.
19
+ * @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
20
+ */
21
+
22
+ import { connect as netConnect } from 'node:net'
23
+ import { createServer, type Server, type Socket } from 'node:net'
24
+ import { createHmac, timingSafeEqual } from 'node:crypto'
25
+
26
+ /** One parsed HTTP/1.1 request head. */
27
+ interface ParsedHead {
28
+ malformed: boolean
29
+ headBytes: number
30
+ method: string
31
+ target: string
32
+ version: string
33
+ headers: Array<[string, string]>
34
+ map: Record<string, string>
35
+ }
36
+
37
+ /** Options for {@link startGateway}. */
38
+ export interface GatewayOptions {
39
+ /** Loopback target port (the DSH instance to proxy by default). */
40
+ targetPort: number
41
+ /** Listen port; pass 0 to let the OS pick one. */
42
+ port: number
43
+ /** Login token (secret). */
44
+ token: string
45
+ /** Instance label shown on the login page. */
46
+ name: string
47
+ /** Session cookie lifetime in hours. */
48
+ maxAgeHours?: number
49
+ /**
50
+ * When set, the gateway routes `/gw/<port>/<path>` to `127.0.0.1:<port>`
51
+ * for any port in this list (the wall's phone panes). Other ports are
52
+ * rejected, so the phone cannot reach arbitrary loopback services.
53
+ */
54
+ routedPorts?: number[]
55
+ /** Log lines (startup, login events). */
56
+ log?: (message: string) => void
57
+ }
58
+
59
+ /** Handle returned by {@link startGateway}. */
60
+ export interface GatewayHandle {
61
+ /** The OS-assigned listen port (equals `port` unless it was 0). */
62
+ port: number
63
+ /** The login token. */
64
+ token: string
65
+ /** Close the gateway listener. */
66
+ close: () => void
67
+ }
68
+
69
+ /** Constant-time hex string comparison (length-guarded). */
70
+ function safeEqualHex(left: string, right: string): boolean {
71
+ const a = Buffer.from(left, 'hex')
72
+ const b = Buffer.from(right, 'hex')
73
+ return a.length !== 0 && a.length === b.length && timingSafeEqual(a, b)
74
+ }
75
+
76
+ /** Minimal HTTP/1.1 head parse; null until the head is complete. */
77
+ function parseHead(buffer: Buffer): ParsedHead | null {
78
+ const idx = buffer.indexOf('\r\n\r\n')
79
+ if (idx === -1) return null
80
+ const head = buffer.subarray(0, idx + 4).toString('latin1')
81
+ const lines = head.split('\r\n')
82
+ const requestLine = lines[0]?.match(/^(\S+)\s+(\S+)\s+(HTTP\/\d\.\d)$/)
83
+ const headers: Array<[string, string]> = []
84
+ const map: Record<string, string> = {}
85
+ for (const line of lines.slice(1)) {
86
+ const ci = line.indexOf(':')
87
+ if (ci === -1) continue
88
+ const name = line.slice(0, ci).trim().toLowerCase()
89
+ const value = line.slice(ci + 1).trim()
90
+ map[name] = value
91
+ headers.push([name, value])
92
+ }
93
+ return {
94
+ malformed: requestLine === null,
95
+ headBytes: idx + 4,
96
+ method: requestLine?.[1] ?? '',
97
+ target: requestLine?.[2] ?? '',
98
+ version: requestLine?.[3] ?? '',
99
+ headers,
100
+ map,
101
+ }
102
+ }
103
+
104
+ /** Pathname of a request target (defaults to '/' on parse failure). */
105
+ function targetPath(target: string): string {
106
+ try {
107
+ return new URL(target, 'http://gateway.local').pathname
108
+ } catch {
109
+ return '/'
110
+ }
111
+ }
112
+
113
+ /** The dark-themed login page. */
114
+ function loginPage(name: string, next: string): string {
115
+ const nxt = (next ?? '').replace(/"/g, '&quot;')
116
+ return `<!doctype html>
117
+ <html lang="zh-CN">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1">
121
+ <title>${name} · 访问认证</title>
122
+ <style>
123
+ body { font-family: system-ui, sans-serif; background:#0d1117; color:#e6edf3;
124
+ display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
125
+ form { background:#161b22; border:1px solid #30363d; border-radius:12px;
126
+ padding:32px 36px; width:min(320px, 90vw); box-shadow:0 8px 30px #0006; }
127
+ h1 { font-size:16px; margin:0 0 18px; font-weight:600; }
128
+ label { font-size:13px; color:#8b949e; }
129
+ input { width:100%; box-sizing:border-box; margin:6px 0 16px; padding:9px 10px;
130
+ border-radius:8px; border:1px solid #30363d; background:#0d1117; color:#e6edf3; font-size:14px; }
131
+ button { width:100%; padding:10px; border:none; border-radius:8px; background:#2f81f7;
132
+ color:#fff; font-size:14px; font-weight:600; cursor:pointer; }
133
+ button:hover { background:#388bfd; }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <form method="post" action="/__gw__/login">
138
+ <h1>${name} · 需要访问口令</h1>
139
+ <label for="token">访问口令</label>
140
+ <input id="token" name="token" type="password" autofocus autocomplete="current-password">
141
+ <input type="hidden" name="next" value="${nxt}">
142
+ <button type="submit">进入</button>
143
+ </form>
144
+ </body>
145
+ </html>`
146
+ }
147
+
148
+ /** Send the login page as a 401 HTML response. */
149
+ function respondLogin(socket: Socket, name: string, next: string): void {
150
+ const body = Buffer.from(loginPage(name, next), 'utf8')
151
+ socket.write(
152
+ 'HTTP/1.1 401 Unauthorized\r\n'
153
+ + 'content-type: text/html; charset=utf-8\r\n'
154
+ + `content-length: ${body.length}\r\n`
155
+ + 'connection: close\r\n'
156
+ + 'cache-control: no-store\r\n\r\n',
157
+ )
158
+ socket.write(body)
159
+ socket.end()
160
+ }
161
+
162
+ /** Send a small plain-text response and close the socket. */
163
+ function respondPlain(socket: Socket, status: string, text: string): void {
164
+ const body = Buffer.from(text, 'utf8')
165
+ socket.write(
166
+ `HTTP/1.1 ${status}\r\n`
167
+ + 'content-type: text/plain; charset=utf-8\r\n'
168
+ + `content-length: ${body.length}\r\n`
169
+ + 'connection: close\r\n'
170
+ + 'cache-control: no-store\r\n\r\n',
171
+ )
172
+ socket.write(body)
173
+ socket.end()
174
+ }
175
+
176
+ /**
177
+ * Send a 302 that mints a session cookie and drops the `token` query param.
178
+ * A `?token=` URL authenticates exactly one request, so on first arrival the
179
+ * gateway exchanges it for an HMAC-signed cookie and redirects to the same
180
+ * path without `token=` (keeps the secret out of the address bar / history).
181
+ */
182
+ function respondSessionRedirect(socket: Socket, parsed: ParsedHead, cookie: string): void {
183
+ let location = parsed.target
184
+ try {
185
+ const url = new URL(parsed.target, 'http://gateway.local')
186
+ url.searchParams.delete('token')
187
+ location = url.pathname + url.search
188
+ } catch { /* keep raw target */ }
189
+ socket.write(
190
+ 'HTTP/1.1 302 Found\r\n'
191
+ + `location: ${location}\r\n`
192
+ + `set-cookie: ${cookie}\r\n`
193
+ + 'content-length: 0\r\n'
194
+ + 'connection: close\r\n'
195
+ + 'cache-control: no-store\r\n\r\n',
196
+ )
197
+ socket.end()
198
+ }
199
+
200
+ /**
201
+ * `crypto.randomUUID` exists only in a *secure context* (HTTPS or localhost).
202
+ * A phone reaching the instance via `http://<LAN-IP>:<gateway-port>` is an
203
+ * insecure origin, so the official DSH client throws
204
+ * `crypto.randomUUID is not a function` on its first RPC and renders blank.
205
+ * This polyfill supplies the same RFC-4122 v4 form using
206
+ * `crypto.getRandomValues`, which insecure origins still expose.
207
+ */
208
+ const POLYFILL_SCRIPT = '<script>(function(){'
209
+ + 'if(typeof crypto!=="undefined"&&typeof crypto.randomUUID==="undefined"&&typeof crypto.getRandomValues==="function"){'
210
+ + 'crypto.randomUUID=function(){'
211
+ + 'var b=crypto.getRandomValues(new Uint8Array(16));'
212
+ + 'b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;'
213
+ + 'var h=Array.prototype.map.call(b,function(x){return x.toString(16).padStart(2,"0")}).join("");'
214
+ + 'return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20);'
215
+ + '};}})();</script>'
216
+
217
+ /**
218
+ * Rewrite a buffered HTML response body to inject the `randomUUID` polyfill
219
+ * into the top of `<head>` (before the DSH bootstrap script runs). Returns the
220
+ * new body, or `null` when the polyfill is not needed (already present, or the
221
+ * response carries no `<head>` to inject into).
222
+ */
223
+ function injectPolyfill(body: Buffer): Buffer | null {
224
+ const html = body.toString('utf8')
225
+ if (html.includes('randomUUID')) return null
226
+ const headMatch = /<head[^>]*>/i.exec(html)
227
+ if (headMatch === null) return null
228
+ const insertAt = headMatch.index + headMatch[0].length
229
+ return Buffer.from(html.slice(0, insertAt) + POLYFILL_SCRIPT + html.slice(insertAt), 'utf8')
230
+ }
231
+
232
+ /**
233
+ * Rebuild the request head for the loopback target. Host and Origin point at
234
+ * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
235
+ * request. Non-upgrade requests get `connection: close` (fresh connection per
236
+ * request keeps keep-alive from smuggling unrewritten Host headers); upgrades
237
+ * (WebSocket) keep `connection: Upgrade`. When `pathOverride` is set (a
238
+ * `/gw/<port>` route), the request line's path becomes the de-prefixed path.
239
+ */
240
+ function rewriteHead(parsed: ParsedHead, targetPort: number, pathOverride?: string): string {
241
+ const upgrade = parsed.map.upgrade !== undefined
242
+ const requestTarget = pathOverride !== undefined ? pathOverride : parsed.target
243
+ const lines = [`${parsed.method} ${requestTarget} ${parsed.version}`]
244
+ for (const [name, value] of parsed.headers) {
245
+ if (name === 'host') {
246
+ lines.push(`host: 127.0.0.1:${targetPort}`)
247
+ } else if (name === 'origin') {
248
+ lines.push(`origin: http://127.0.0.1:${targetPort}`)
249
+ } else if (name === 'connection' || name === 'proxy-connection') {
250
+ // dropped; re-added below with the right semantics
251
+ } else {
252
+ lines.push(`${name}: ${value}`)
253
+ }
254
+ }
255
+ lines.push(upgrade ? 'connection: Upgrade' : 'connection: close')
256
+ return lines.join('\r\n') + '\r\n\r\n'
257
+ }
258
+
259
+ /**
260
+ * Start an in-process authenticated gateway for one loopback DSH instance.
261
+ * @param options - target, listen port, token, label, and lifetime.
262
+ * @returns a handle with the assigned port and token, and a close().
263
+ */
264
+ export function startGateway(options: GatewayOptions): Promise<GatewayHandle> {
265
+ const {
266
+ targetPort,
267
+ port: requestedPort,
268
+ token,
269
+ name,
270
+ maxAgeHours = 12,
271
+ routedPorts,
272
+ log = () => {},
273
+ } = options
274
+
275
+ const allowedRouted = new Set<number>(routedPorts ?? [])
276
+
277
+ const hmac = (data: string) => createHmac('sha256', token).update(data).digest('hex')
278
+ const signSession = (exp: number) => {
279
+ const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url')
280
+ return `${payload}.${hmac(payload)}`
281
+ }
282
+ const verifySession = (value: string) => {
283
+ const dot = value.lastIndexOf('.')
284
+ if (dot <= 0) return false
285
+ const payload = value.slice(0, dot)
286
+ const sig = value.slice(dot + 1)
287
+ if (!safeEqualHex(sig, hmac(payload))) return false
288
+ try {
289
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp?: unknown }
290
+ return typeof parsed.exp === 'number' && parsed.exp > Date.now()
291
+ } catch {
292
+ return false
293
+ }
294
+ }
295
+
296
+ /**
297
+ * How a request authenticates. When the answer is `query` (only the
298
+ * `?token=` matched), the caller must also mint a session cookie — a query
299
+ * token authenticates exactly one request, so without a cookie the page's
300
+ * follow-up fetches would 401 and render blank.
301
+ */
302
+ type AuthKind = 'cookie' | 'bearer' | 'query' | 'none'
303
+
304
+ const authKind = (parsed: ParsedHead): AuthKind => {
305
+ const cookie = parsed.map.cookie ?? ''
306
+ const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie)
307
+ if (session !== null && verifySession(session[1] ?? '')) return 'cookie'
308
+ const bearer = parsed.map.authorization ?? ''
309
+ if (bearer.startsWith('Bearer ') && bearer.slice(7) === token) return 'bearer'
310
+ try {
311
+ if (new URL(parsed.target, 'http://gateway.local').searchParams.get('token') === token) return 'query'
312
+ } catch { /* fall through */ }
313
+ return 'none'
314
+ }
315
+
316
+ // Rate limiting per source IP (login POST only).
317
+ const attempts = new Map<string, { fails: number; blockedUntil?: number }>()
318
+ const isBlocked = (ip: string) => {
319
+ const entry = attempts.get(ip)
320
+ return entry !== undefined && entry.blockedUntil !== undefined && entry.blockedUntil > Date.now()
321
+ }
322
+ const registerFailure = (ip: string) => {
323
+ const now = Date.now()
324
+ const entry = attempts.get(ip) ?? { fails: 0 }
325
+ if (entry.blockedUntil !== undefined && entry.blockedUntil > now) return
326
+ entry.fails += 1
327
+ if (entry.fails >= 5) {
328
+ entry.fails = 0
329
+ entry.blockedUntil = now + 60_000
330
+ log(`rate-limited ${ip} for 60s`)
331
+ }
332
+ attempts.set(ip, entry)
333
+ if (attempts.size > 512) {
334
+ for (const [key, value] of attempts) {
335
+ if (value.fails === 0 && value.blockedUntil === undefined) attempts.delete(key)
336
+ }
337
+ }
338
+ }
339
+
340
+ const handleLogin = (socket: Socket, parsed: ParsedHead, initialBody: Buffer): void => {
341
+ let next = '/'
342
+ try {
343
+ next = new URL(parsed.target, 'http://gateway.local').searchParams.get('next') ?? '/'
344
+ } catch { /* keep default */ }
345
+ if (!/^\/(?!\/)/.test(next)) next = '/'
346
+
347
+ if (parsed.method !== 'POST') {
348
+ respondLogin(socket, name, next)
349
+ return
350
+ }
351
+ const ip = socket.remoteAddress ?? 'unknown'
352
+ if (isBlocked(ip)) {
353
+ respondPlain(socket, '429', 'too many login attempts, try again later')
354
+ return
355
+ }
356
+ const contentLength = Number(parsed.map['content-length'] ?? 0)
357
+ if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 16384) {
358
+ respondPlain(socket, '413', 'login body too large')
359
+ return
360
+ }
361
+
362
+ let collected = Buffer.from(initialBody)
363
+ const done = () => {
364
+ socket.off('data', onBody)
365
+ const submitted = new URLSearchParams(collected.toString('utf8')).get('token') ?? ''
366
+ if (submitted === token) {
367
+ const cookie = `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 3600_000)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`
368
+ socket.write(
369
+ 'HTTP/1.1 302 Found\r\n'
370
+ + `location: ${next}\r\n`
371
+ + `set-cookie: ${cookie}\r\n`
372
+ + 'content-length: 0\r\n'
373
+ + 'connection: close\r\n\r\n',
374
+ )
375
+ socket.end()
376
+ log(`login ok from ${ip}`)
377
+ } else {
378
+ registerFailure(ip)
379
+ log(`login FAILED from ${ip}`)
380
+ respondLogin(socket, name, next)
381
+ }
382
+ }
383
+ const onBody = (chunk: Buffer) => {
384
+ collected = Buffer.concat([collected, chunk])
385
+ if (collected.length >= contentLength) done()
386
+ }
387
+ if (collected.length >= contentLength) {
388
+ done()
389
+ } else {
390
+ socket.on('data', onBody)
391
+ socket.once('end', done)
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
397
+ * bytes. Returns `null` on malformed framing (caller then relays verbatim).
398
+ */
399
+ const decodeChunked = (raw: Buffer): Buffer | null => {
400
+ const out: Buffer[] = []
401
+ let i = 0
402
+ const text = raw.toString('latin1')
403
+ while (true) {
404
+ const crlf = text.indexOf('\r\n', i)
405
+ if (crlf === -1) return null
406
+ const sizeHex = text.slice(i, crlf).trim()
407
+ const size = parseInt(sizeHex, 16)
408
+ if (!Number.isFinite(size) || size < 0) return null
409
+ i = crlf + 2
410
+ if (size === 0) {
411
+ // trailing chunk: consume optional trailers up to the terminating CRLF.
412
+ return Buffer.concat(out)
413
+ }
414
+ if (i + size > raw.length) return null
415
+ out.push(raw.subarray(i, i + size))
416
+ i += size + 2 // skip data + CRLF
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Relay the DSH target's response to the phone client. HTML document
422
+ * responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
423
+ * injected before the bootstrap script runs; every other response
424
+ * (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
425
+ * so streaming and framing stay untouched.
426
+ */
427
+ const relayTarget = (socket: Socket, target: Socket): void => {
428
+ let head: Buffer = Buffer.alloc(0)
429
+ let bodyParts: Buffer[] | null = null
430
+ let relayed = false
431
+
432
+ const pipeThrough = () => {
433
+ if (relayed) return
434
+ relayed = true
435
+ if (head.length > 0) socket.write(head)
436
+ if (bodyParts !== null && bodyParts.length > 0) {
437
+ socket.write(Buffer.concat(bodyParts))
438
+ bodyParts = null
439
+ }
440
+ target.pipe(socket)
441
+ }
442
+
443
+ const finishInjection = (fullHead: Buffer, fullBody: Buffer): void => {
444
+ const rewritten = injectPolyfill(fullBody)
445
+ if (rewritten === null) {
446
+ socket.write(fullHead)
447
+ socket.write(fullBody)
448
+ socket.end()
449
+ return
450
+ }
451
+ // Strip framing we can no longer honor (chunked / keep-alive) and emit
452
+ // the whole document with an exact content-length + connection: close.
453
+ const lines = fullHead.toString('latin1').split('\r\n')
454
+ const filtered: string[] = []
455
+ for (const line of lines) {
456
+ const lower = line.toLowerCase()
457
+ if (lower.startsWith('transfer-encoding:')) continue
458
+ if (lower.startsWith('content-length:')) continue
459
+ if (lower.startsWith('connection:')) continue
460
+ filtered.push(line)
461
+ }
462
+ // Drop the trailing blank line(s) that separate head from body.
463
+ while (filtered.length > 0 && filtered[filtered.length - 1] === '') filtered.pop()
464
+ filtered.push(`content-length: ${rewritten.length}`, 'connection: close', '', '')
465
+ socket.write(Buffer.from(filtered.join('\r\n'), 'latin1'))
466
+ socket.write(rewritten)
467
+ socket.end()
468
+ }
469
+
470
+ target.on('data', (chunk: Buffer) => {
471
+ if (relayed) return
472
+ const idx = head.indexOf('\r\n\r\n')
473
+ if (idx === -1) {
474
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk])
475
+ const newIdx = head.indexOf('\r\n\r\n')
476
+ if (newIdx === -1) {
477
+ if (head.length > 65536) pipeThrough()
478
+ return
479
+ }
480
+ // Head just completed: decide now whether to buffer the body.
481
+ const headBuf = head.subarray(0, newIdx + 4)
482
+ const bodyStart = head.subarray(newIdx + 4)
483
+ const headText = headBuf.toString('latin1')
484
+ const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? ''
485
+ const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
486
+ const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
487
+ const isHtml = status === '200' && /text\/html/i.test(contentType)
488
+ const compressed = ce !== '' && !/identity/i.test(ce)
489
+ if (!isHtml || compressed) {
490
+ pipeThrough()
491
+ return
492
+ }
493
+ bodyParts = bodyStart.length > 0 ? [bodyStart] : []
494
+ return
495
+ }
496
+ // Head already parsed and we chose to buffer: append body.
497
+ if (bodyParts !== null) bodyParts.push(chunk)
498
+ })
499
+
500
+ target.once('end', () => {
501
+ if (relayed || bodyParts === null) {
502
+ if (!relayed) socket.end()
503
+ return
504
+ }
505
+ const headBuf = head.subarray(0, head.indexOf('\r\n\r\n') + 4)
506
+ const headText = headBuf.toString('latin1')
507
+ const rawBody = Buffer.concat(bodyParts)
508
+ const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? ''
509
+ const body = /chunked/i.test(te) ? (decodeChunked(rawBody) ?? rawBody) : rawBody
510
+ finishInjection(headBuf, body)
511
+ })
512
+
513
+ target.once('error', () => socket.destroy())
514
+ }
515
+
516
+ const handleSocket = (socket: Socket): void => {
517
+ let buffer: Buffer = Buffer.alloc(0)
518
+ let headDone = false
519
+ let proxying = false
520
+
521
+ const onData = (chunk: Buffer) => {
522
+ if (headDone) return
523
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk])
524
+ if (buffer.length > 65536) {
525
+ headDone = true
526
+ respondPlain(socket, '431', 'request head too large')
527
+ return
528
+ }
529
+ const parsed = parseHead(buffer)
530
+ if (parsed === null) return
531
+
532
+ headDone = true
533
+ if (parsed.malformed) {
534
+ respondPlain(socket, '400', 'malformed request')
535
+ return
536
+ }
537
+
538
+ const path = targetPath(parsed.target)
539
+ if (path === '/__gw__/login') {
540
+ handleLogin(socket, parsed, buffer.subarray(parsed.headBytes))
541
+ return
542
+ }
543
+
544
+ // `/gw/<port>/<path>` routes to a sibling loopback instance (the wall's
545
+ // phone panes). Only ports the host explicitly allowed are reachable, so
546
+ // the phone cannot probe arbitrary loopback services.
547
+ let routePort: number | null = null
548
+ let routePath: string | undefined
549
+ const routeMatch = /^\/gw\/(\d+)(\/.*)?$/.exec(path)
550
+ if (routeMatch !== null) {
551
+ const candidate = Number(routeMatch[1])
552
+ if (!Number.isInteger(candidate) || candidate <= 0 || !allowedRouted.has(candidate)) {
553
+ respondPlain(socket, '403', 'port not routable')
554
+ return
555
+ }
556
+ routePort = candidate
557
+ routePath = routeMatch[2] && routeMatch[2] !== '' ? routeMatch[2] : '/'
558
+ }
559
+ const effectivePort = routePort ?? targetPort
560
+
561
+ const kind = authKind(parsed)
562
+ if (kind === 'none') {
563
+ respondLogin(socket, name, path === '/' ? '/' : path)
564
+ return
565
+ }
566
+ // A `?token=` query authenticates only this request; exchange it for a
567
+ // session cookie and redirect (token dropped) so the browser stays
568
+ // authorized across the page's follow-up fetches without carrying the
569
+ // secret in every URL.
570
+ if (kind === 'query') {
571
+ const cookie = `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 3600_000)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`
572
+ respondSessionRedirect(socket, parsed, cookie)
573
+ return
574
+ }
575
+
576
+ const target = netConnect(effectivePort, '127.0.0.1')
577
+ const rest = buffer.subarray(parsed.headBytes)
578
+ let connected = false
579
+ target.on('connect', () => {
580
+ connected = true
581
+ target.write(rewriteHead(parsed, effectivePort, routePath))
582
+ if (rest.length > 0) target.write(rest)
583
+ proxying = true
584
+ socket.pipe(target)
585
+ relayTarget(socket, target)
586
+ })
587
+ target.on('error', (error: NodeJS.ErrnoException) => {
588
+ if (!connected) {
589
+ respondPlain(socket, '502', `cannot reach target 127.0.0.1:${effectivePort}: ${error.message}`)
590
+ }
591
+ socket.destroy()
592
+ })
593
+ socket.on('error', () => target.destroy())
594
+ socket.on('close', () => { if (proxying) target.destroy() })
595
+ }
596
+
597
+ socket.on('data', onData)
598
+ socket.on('error', () => { socket.destroy() })
599
+ }
600
+
601
+ return new Promise((resolve, reject) => {
602
+ const server: Server = createServer(handleSocket)
603
+ const onError = (error: NodeJS.ErrnoException) => {
604
+ // EACCES (Windows excluded port range / no bind permission) and
605
+ // EADDRINUSE (port taken) fall back to an OS-assigned free port.
606
+ if ((error.code === 'EACCES' || error.code === 'EADDRINUSE') && requestedPort !== 0) {
607
+ log(`gateway port ${requestedPort} unavailable (${error.code}), using an OS-assigned port`)
608
+ server.listen(0, '0.0.0.0')
609
+ return
610
+ }
611
+ server.removeListener('error', onError)
612
+ reject(error)
613
+ }
614
+ server.on('error', onError)
615
+ server.listen(requestedPort, '0.0.0.0', () => {
616
+ server.removeListener('error', onError)
617
+ const address = server.address()
618
+ const assignedPort = typeof address === 'object' && address !== null ? address.port : requestedPort
619
+ log(`gateway listening 0.0.0.0:${assignedPort} -> 127.0.0.1:${targetPort}`)
620
+ resolve({
621
+ port: assignedPort,
622
+ token,
623
+ close: () => { server.close() },
624
+ })
625
+ })
626
+ })
627
+ }