dsh-multi-chat 0.5.0 → 0.5.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/package.json
CHANGED
|
@@ -18,6 +18,9 @@ import { connect, createServer } from "node:net";
|
|
|
18
18
|
* `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
|
|
19
19
|
* browser-trust fence treats it as a local request. WebSocket upgrades pass
|
|
20
20
|
* through untouched because the socket is piped byte-for-byte after the head.
|
|
21
|
+
* The HTML document response is buffered once so a `crypto.randomUUID`
|
|
22
|
+
* polyfill can be injected for phones (insecure-origin HTTP); all other
|
|
23
|
+
* responses stream through unchanged.
|
|
21
24
|
*
|
|
22
25
|
* Zero dependencies: node:net + node:crypto only.
|
|
23
26
|
* @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
|
|
@@ -119,6 +122,50 @@ cache-control: no-store\r
|
|
|
119
122
|
socket.end();
|
|
120
123
|
}
|
|
121
124
|
/**
|
|
125
|
+
* Send a 302 that mints a session cookie and drops the `token` query param.
|
|
126
|
+
* A `?token=` URL authenticates exactly one request, so on first arrival the
|
|
127
|
+
* gateway exchanges it for an HMAC-signed cookie and redirects to the same
|
|
128
|
+
* path without `token=` (keeps the secret out of the address bar / history).
|
|
129
|
+
*/
|
|
130
|
+
function respondSessionRedirect(socket, parsed, cookie) {
|
|
131
|
+
let location = parsed.target;
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(parsed.target, "http://gateway.local");
|
|
134
|
+
url.searchParams.delete("token");
|
|
135
|
+
location = url.pathname + url.search;
|
|
136
|
+
} catch {}
|
|
137
|
+
socket.write(`HTTP/1.1 302 Found\r
|
|
138
|
+
location: ${location}\r\nset-cookie: ${cookie}\r\ncontent-length: 0\r
|
|
139
|
+
connection: close\r
|
|
140
|
+
cache-control: no-store\r
|
|
141
|
+
\r
|
|
142
|
+
`);
|
|
143
|
+
socket.end();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `crypto.randomUUID` exists only in a *secure context* (HTTPS or localhost).
|
|
147
|
+
* A phone reaching the instance via `http://<LAN-IP>:<gateway-port>` is an
|
|
148
|
+
* insecure origin, so the official DSH client throws
|
|
149
|
+
* `crypto.randomUUID is not a function` on its first RPC and renders blank.
|
|
150
|
+
* This polyfill supplies the same RFC-4122 v4 form using
|
|
151
|
+
* `crypto.getRandomValues`, which insecure origins still expose.
|
|
152
|
+
*/
|
|
153
|
+
const POLYFILL_SCRIPT = "<script>(function(){if(typeof crypto!==\"undefined\"&&typeof crypto.randomUUID===\"undefined\"&&typeof crypto.getRandomValues===\"function\"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=Array.prototype.map.call(b,function(x){return x.toString(16).padStart(2,\"0\")}).join(\"\");return h.slice(0,8)+\"-\"+h.slice(8,12)+\"-\"+h.slice(12,16)+\"-\"+h.slice(16,20)+\"-\"+h.slice(20);};}})();<\/script>";
|
|
154
|
+
/**
|
|
155
|
+
* Rewrite a buffered HTML response body to inject the `randomUUID` polyfill
|
|
156
|
+
* into the top of `<head>` (before the DSH bootstrap script runs). Returns the
|
|
157
|
+
* new body, or `null` when the polyfill is not needed (already present, or the
|
|
158
|
+
* response carries no `<head>` to inject into).
|
|
159
|
+
*/
|
|
160
|
+
function injectPolyfill(body) {
|
|
161
|
+
const html = body.toString("utf8");
|
|
162
|
+
if (html.includes("randomUUID")) return null;
|
|
163
|
+
const headMatch = /<head[^>]*>/i.exec(html);
|
|
164
|
+
if (headMatch === null) return null;
|
|
165
|
+
const insertAt = headMatch.index + headMatch[0].length;
|
|
166
|
+
return Buffer.from(html.slice(0, insertAt) + POLYFILL_SCRIPT + html.slice(insertAt), "utf8");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
122
169
|
* Rebuild the request head for the loopback target. Host and Origin point at
|
|
123
170
|
* `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
|
|
124
171
|
* request. Non-upgrade requests get `connection: close` (fresh connection per
|
|
@@ -158,17 +205,16 @@ function startGateway(options) {
|
|
|
158
205
|
return false;
|
|
159
206
|
}
|
|
160
207
|
};
|
|
161
|
-
const
|
|
208
|
+
const authKind = (parsed) => {
|
|
162
209
|
const cookie = parsed.map.cookie ?? "";
|
|
163
210
|
const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie);
|
|
164
|
-
if (session !== null && verifySession(session[1] ?? "")) return
|
|
211
|
+
if (session !== null && verifySession(session[1] ?? "")) return "cookie";
|
|
165
212
|
const bearer = parsed.map.authorization ?? "";
|
|
166
|
-
if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return
|
|
213
|
+
if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return "bearer";
|
|
167
214
|
try {
|
|
168
|
-
|
|
169
|
-
} catch {
|
|
170
|
-
|
|
171
|
-
}
|
|
215
|
+
if (new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token) return "query";
|
|
216
|
+
} catch {}
|
|
217
|
+
return "none";
|
|
172
218
|
};
|
|
173
219
|
const attempts = /* @__PURE__ */ new Map();
|
|
174
220
|
const isBlocked = (ip) => {
|
|
@@ -238,6 +284,110 @@ connection: close\r
|
|
|
238
284
|
socket.once("end", done);
|
|
239
285
|
}
|
|
240
286
|
};
|
|
287
|
+
/**
|
|
288
|
+
* Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
|
|
289
|
+
* bytes. Returns `null` on malformed framing (caller then relays verbatim).
|
|
290
|
+
*/
|
|
291
|
+
const decodeChunked = (raw) => {
|
|
292
|
+
const out = [];
|
|
293
|
+
let i = 0;
|
|
294
|
+
const text = raw.toString("latin1");
|
|
295
|
+
while (true) {
|
|
296
|
+
const crlf = text.indexOf("\r\n", i);
|
|
297
|
+
if (crlf === -1) return null;
|
|
298
|
+
const sizeHex = text.slice(i, crlf).trim();
|
|
299
|
+
const size = parseInt(sizeHex, 16);
|
|
300
|
+
if (!Number.isFinite(size) || size < 0) return null;
|
|
301
|
+
i = crlf + 2;
|
|
302
|
+
if (size === 0) return Buffer.concat(out);
|
|
303
|
+
if (i + size > raw.length) return null;
|
|
304
|
+
out.push(raw.subarray(i, i + size));
|
|
305
|
+
i += size + 2;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
/**
|
|
309
|
+
* Relay the DSH target's response to the phone client. HTML document
|
|
310
|
+
* responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
|
|
311
|
+
* injected before the bootstrap script runs; every other response
|
|
312
|
+
* (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
|
|
313
|
+
* so streaming and framing stay untouched.
|
|
314
|
+
*/
|
|
315
|
+
const relayTarget = (socket, target) => {
|
|
316
|
+
let head = Buffer.alloc(0);
|
|
317
|
+
let bodyParts = null;
|
|
318
|
+
let relayed = false;
|
|
319
|
+
const pipeThrough = () => {
|
|
320
|
+
if (relayed) return;
|
|
321
|
+
relayed = true;
|
|
322
|
+
if (head.length > 0) socket.write(head);
|
|
323
|
+
if (bodyParts !== null && bodyParts.length > 0) {
|
|
324
|
+
socket.write(Buffer.concat(bodyParts));
|
|
325
|
+
bodyParts = null;
|
|
326
|
+
}
|
|
327
|
+
target.pipe(socket);
|
|
328
|
+
};
|
|
329
|
+
const finishInjection = (fullHead, fullBody) => {
|
|
330
|
+
const rewritten = injectPolyfill(fullBody);
|
|
331
|
+
if (rewritten === null) {
|
|
332
|
+
socket.write(fullHead);
|
|
333
|
+
socket.write(fullBody);
|
|
334
|
+
socket.end();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const lines = fullHead.toString("latin1").split("\r\n");
|
|
338
|
+
const filtered = [];
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
const lower = line.toLowerCase();
|
|
341
|
+
if (lower.startsWith("transfer-encoding:")) continue;
|
|
342
|
+
if (lower.startsWith("content-length:")) continue;
|
|
343
|
+
if (lower.startsWith("connection:")) continue;
|
|
344
|
+
filtered.push(line);
|
|
345
|
+
}
|
|
346
|
+
while (filtered.length > 0 && filtered[filtered.length - 1] === "") filtered.pop();
|
|
347
|
+
filtered.push(`content-length: ${rewritten.length}`, "connection: close", "", "");
|
|
348
|
+
socket.write(Buffer.from(filtered.join("\r\n"), "latin1"));
|
|
349
|
+
socket.write(rewritten);
|
|
350
|
+
socket.end();
|
|
351
|
+
};
|
|
352
|
+
target.on("data", (chunk) => {
|
|
353
|
+
if (relayed) return;
|
|
354
|
+
if (head.indexOf("\r\n\r\n") === -1) {
|
|
355
|
+
head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
|
|
356
|
+
const newIdx = head.indexOf("\r\n\r\n");
|
|
357
|
+
if (newIdx === -1) {
|
|
358
|
+
if (head.length > 65536) pipeThrough();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const headBuf = head.subarray(0, newIdx + 4);
|
|
362
|
+
const bodyStart = head.subarray(newIdx + 4);
|
|
363
|
+
const headText = headBuf.toString("latin1");
|
|
364
|
+
const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? "";
|
|
365
|
+
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
366
|
+
const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
367
|
+
const isHtml = status === "200" && /text\/html/i.test(contentType);
|
|
368
|
+
const compressed = ce !== "" && !/identity/i.test(ce);
|
|
369
|
+
if (!isHtml || compressed) {
|
|
370
|
+
pipeThrough();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
bodyParts = bodyStart.length > 0 ? [bodyStart] : [];
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (bodyParts !== null) bodyParts.push(chunk);
|
|
377
|
+
});
|
|
378
|
+
target.once("end", () => {
|
|
379
|
+
if (relayed || bodyParts === null) {
|
|
380
|
+
if (!relayed) socket.end();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const headBuf = head.subarray(0, head.indexOf("\r\n\r\n") + 4);
|
|
384
|
+
const headText = headBuf.toString("latin1");
|
|
385
|
+
const rawBody = Buffer.concat(bodyParts);
|
|
386
|
+
const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
387
|
+
finishInjection(headBuf, /chunked/i.test(te) ? decodeChunked(rawBody) ?? rawBody : rawBody);
|
|
388
|
+
});
|
|
389
|
+
target.once("error", () => socket.destroy());
|
|
390
|
+
};
|
|
241
391
|
const handleSocket = (socket) => {
|
|
242
392
|
let buffer = Buffer.alloc(0);
|
|
243
393
|
let headDone = false;
|
|
@@ -262,10 +412,15 @@ connection: close\r
|
|
|
262
412
|
handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
|
|
263
413
|
return;
|
|
264
414
|
}
|
|
265
|
-
|
|
415
|
+
const kind = authKind(parsed);
|
|
416
|
+
if (kind === "none") {
|
|
266
417
|
respondLogin(socket, name, path === "/" ? "/" : path);
|
|
267
418
|
return;
|
|
268
419
|
}
|
|
420
|
+
if (kind === "query") {
|
|
421
|
+
respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
269
424
|
const target = connect(targetPort, "127.0.0.1");
|
|
270
425
|
const rest = buffer.subarray(parsed.headBytes);
|
|
271
426
|
let connected = false;
|
|
@@ -275,7 +430,7 @@ connection: close\r
|
|
|
275
430
|
if (rest.length > 0) target.write(rest);
|
|
276
431
|
proxying = true;
|
|
277
432
|
socket.pipe(target);
|
|
278
|
-
|
|
433
|
+
relayTarget(socket, target);
|
|
279
434
|
});
|
|
280
435
|
target.on("error", (error) => {
|
|
281
436
|
if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);
|