@wenbin_wb/dsh-bridge 2.10.3 → 2.10.4
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/CHANGELOG.md +18 -0
- package/client/client.js +61 -13
- package/client/index.js +45 -14
- package/client/mobile-styles.js +834 -802
- package/lib/auth/login-template.js +417 -382
- package/lib/bridge-rpc.js +2 -2
- package/lib/index.js +22 -5
- package/lib/session-strip.js +14 -4
- package/lib/telegram/gateway.js +28 -26
- package/lib/tunnel-client.mjs +123 -7
- package/package.json +2 -2
package/lib/bridge-rpc.js
CHANGED
|
@@ -157,8 +157,8 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
|
|
|
157
157
|
if (adminErr) return adminErr;
|
|
158
158
|
|
|
159
159
|
// 未提供的字段保持 undefined 透传:服务端视为"保留现值"
|
|
160
|
-
const { serverUrl, accessToken } = payload;
|
|
161
|
-
await saveCustomTunnelConfig(serverUrl, accessToken);
|
|
160
|
+
const { serverUrl, accessToken, sseStreaming } = payload;
|
|
161
|
+
await saveCustomTunnelConfig(serverUrl, accessToken, sseStreaming);
|
|
162
162
|
const status = await service.getStatus();
|
|
163
163
|
return ok(status);
|
|
164
164
|
}
|
package/lib/index.js
CHANGED
|
@@ -184,6 +184,7 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
|
|
|
184
184
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
185
185
|
<link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
|
|
186
186
|
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
187
|
+
<style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
|
|
187
188
|
<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
|
|
188
189
|
${BROWSER_ABORT_SIGNAL_POLYFILL}`;
|
|
189
190
|
const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
|
|
@@ -443,6 +444,10 @@ class ProxyServer {
|
|
|
443
444
|
const isLocked = this.authManager?.isIpBlocked(clientIp);
|
|
444
445
|
const html = renderLoginPage({
|
|
445
446
|
hasPassword: this.authManager?.hasPassword,
|
|
447
|
+
// 管理员是否从未设置过任何密码(含独立管理密码):登录页需如实提示,
|
|
448
|
+
// 避免出现"输入任意密码都能进"的假门禁
|
|
449
|
+
noPasswordConfigured: !this.authManager?.hasPassword && !this.authManager?.hasAdminPassword,
|
|
450
|
+
mode: this.authManager?.mode,
|
|
446
451
|
locked: isLocked,
|
|
447
452
|
error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
|
|
448
453
|
});
|
|
@@ -466,8 +471,13 @@ class ProxyServer {
|
|
|
466
471
|
// 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
|
|
467
472
|
// 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
|
|
468
473
|
const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
|
|
469
|
-
|
|
470
|
-
|
|
474
|
+
// session 投影剥离须覆盖压缩(gzip)响应:v2.10.4 起 stripSessionProjections 支持
|
|
475
|
+
// gunzip 解压,因此压缩的 session 响应也应进入缓冲分支进行剥离(此前 !isCompressed
|
|
476
|
+
// 门控会跳过压缩响应,导致 DSH 启用 gzip 后局域网/CF/外部隧道入口剥离失效——只有
|
|
477
|
+
// 自建隧道入口正确剥离)。HTML 注入仍只在未压缩时进行(注入逻辑处理明文)。
|
|
478
|
+
const sessionProjectionBuffered = isSessionProjection && proxyRes.statusCode === 200;
|
|
479
|
+
const shouldBuffer = (contentType.includes('text/html') && !isCompressed(proxyRes.headers))
|
|
480
|
+
|| sessionProjectionBuffered;
|
|
471
481
|
if (shouldBuffer) {
|
|
472
482
|
const chunks = [];
|
|
473
483
|
proxyRes.on('data', (c) => chunks.push(c));
|
|
@@ -481,8 +491,12 @@ class ProxyServer {
|
|
|
481
491
|
}
|
|
482
492
|
out = Buffer.from(html, 'utf8');
|
|
483
493
|
} else if (isSessionProjection) {
|
|
484
|
-
const { body, stripped } = stripSessionProjections(pathname, out);
|
|
485
|
-
if (stripped)
|
|
494
|
+
const { body, stripped } = stripSessionProjections(pathname, out, proxyRes.headers['content-encoding']);
|
|
495
|
+
if (stripped) {
|
|
496
|
+
out = body;
|
|
497
|
+
// 已修改 body,清除原始压缩编码标记
|
|
498
|
+
delete outHeaders['content-encoding'];
|
|
499
|
+
}
|
|
486
500
|
}
|
|
487
501
|
// 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
|
|
488
502
|
// transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
|
|
@@ -801,6 +815,7 @@ class BridgeService {
|
|
|
801
815
|
configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
|
|
802
816
|
serverUrl: this.customTunnelConfig?.serverUrl ?? '',
|
|
803
817
|
running: !!this.customTunnel?.connected,
|
|
818
|
+
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
804
819
|
url: customUrl,
|
|
805
820
|
rawUrl: baseCustomUrl,
|
|
806
821
|
qr: customUrl
|
|
@@ -896,6 +911,7 @@ class BridgeService {
|
|
|
896
911
|
accessToken,
|
|
897
912
|
localPort: this.proxyPort,
|
|
898
913
|
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
914
|
+
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
899
915
|
onStateChange: (state) => {
|
|
900
916
|
this.customTunnelState = state;
|
|
901
917
|
},
|
|
@@ -1852,13 +1868,14 @@ function apply(ctx, config = {}) {
|
|
|
1852
1868
|
telegram,
|
|
1853
1869
|
platformManager,
|
|
1854
1870
|
logger,
|
|
1855
|
-
saveCustomTunnelConfig: async (serverUrl, accessToken) => {
|
|
1871
|
+
saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
|
|
1856
1872
|
const stored = await updateConfig((current) => {
|
|
1857
1873
|
const prev = service.customTunnelConfig ?? {};
|
|
1858
1874
|
const next = { ...prev };
|
|
1859
1875
|
// 与 saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
|
|
1860
1876
|
if (serverUrl !== undefined) next.serverUrl = String(serverUrl).trim();
|
|
1861
1877
|
if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
|
|
1878
|
+
if (sseStreaming !== undefined) next.sseStreaming = Boolean(sseStreaming);
|
|
1862
1879
|
current.customTunnel = next;
|
|
1863
1880
|
return current;
|
|
1864
1881
|
});
|
package/lib/session-strip.js
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// - tunnel-client.mjs(自建隧道)
|
|
10
10
|
// - index.js ProxyServer(局域网 / Cloudflare 隧道 / 外部隧道登记 —— 所有公网入口)
|
|
11
11
|
// 命中失败(非 200 / 解析失败 / 结构不符)时原样返回,绝不破坏响应。
|
|
12
|
+
//
|
|
13
|
+
// DSH web 服务器默认开启 gzip 压缩(compression: gzip, level 1, threshold 1KB),
|
|
14
|
+
// 所以 API 响应可能是 gzip 编码的。必须先解压才能 JSON.parse,否则解析失败,
|
|
15
|
+
// catch {} 静默跳过剥离 → 完整 58MB+ 响应原样传输 → 隧道慢。
|
|
16
|
+
|
|
17
|
+
import { gunzipSync } from 'node:zlib';
|
|
12
18
|
|
|
13
19
|
const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
|
|
14
20
|
|
|
@@ -16,15 +22,19 @@ const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
|
|
|
16
22
|
* 剥离 session.list / session.history 响应中的 contextHeaders 与 contextTimeline。
|
|
17
23
|
* @param {string} pathname 请求路径(不含 query)
|
|
18
24
|
* @param {Buffer} bodyBuf 原始响应体
|
|
19
|
-
* @
|
|
25
|
+
* @param {string} [contentEncoding] 响应的 content-encoding 头值(用于判断是否 gzip)
|
|
26
|
+
* @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length 和 content-encoding)
|
|
20
27
|
*/
|
|
21
|
-
export function stripSessionProjections(pathname, bodyBuf) {
|
|
28
|
+
export function stripSessionProjections(pathname, bodyBuf, contentEncoding) {
|
|
22
29
|
const cleanPath = String(pathname || '').split('?')[0];
|
|
23
30
|
if (!STRIP_PATHS.has(cleanPath) || !Buffer.isBuffer(bodyBuf)) {
|
|
24
31
|
return { body: bodyBuf, stripped: false };
|
|
25
32
|
}
|
|
26
33
|
try {
|
|
27
|
-
|
|
34
|
+
// 如果 DSH 服务器 gzip 压缩了响应,先解压再解析
|
|
35
|
+
const isGzipped = String(contentEncoding ?? '').toLowerCase() === 'gzip';
|
|
36
|
+
const parseBuf = isGzipped ? gunzipSync(bodyBuf) : bodyBuf;
|
|
37
|
+
const json = JSON.parse(parseBuf.toString('utf8'));
|
|
28
38
|
if (json?.result?.ok) {
|
|
29
39
|
const v = json.result.value;
|
|
30
40
|
let changed = false;
|
|
@@ -52,6 +62,6 @@ export function stripSessionProjections(pathname, bodyBuf) {
|
|
|
52
62
|
return { body: Buffer.from(JSON.stringify(json), 'utf8'), stripped: true };
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
|
-
} catch { /*
|
|
65
|
+
} catch { /* 解析/解压失败则原样发送 */ }
|
|
56
66
|
return { body: bodyBuf, stripped: false };
|
|
57
67
|
}
|
package/lib/telegram/gateway.js
CHANGED
|
@@ -37,34 +37,36 @@ export function createConnectProxyAgent(proxyUrl) {
|
|
|
37
37
|
? 'Basic ' + Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')
|
|
38
38
|
: undefined
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
40
|
+
// Node >=24 下自定义 createConnection 作为 https.Agent 构造参数会被静默忽略
|
|
41
|
+
// (https.Agent 不拷贝构造入参中的 createConnection,回退到原型默认直连方法)。
|
|
42
|
+
// 改为构造后赋值实例属性:实例自有属性会正确覆盖原型方法,Node 22/24 均生效。
|
|
43
|
+
const agent = new https.Agent({ keepAlive: true })
|
|
44
|
+
agent.createConnection = function createTunnelConnection(opts, callback) {
|
|
45
|
+
const connectReq = http.request({
|
|
46
|
+
host: proxyHost,
|
|
47
|
+
port: proxyPort,
|
|
48
|
+
method: 'CONNECT',
|
|
49
|
+
path: `${opts.host}:${opts.port || 443}`,
|
|
50
|
+
headers: authHeader ? { 'Proxy-Authorization': authHeader } : {},
|
|
51
|
+
})
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
callback(null, tlsSocket)
|
|
53
|
+
connectReq.on('connect', (res, socket) => {
|
|
54
|
+
if (res.statusCode !== 200) {
|
|
55
|
+
socket.destroy()
|
|
56
|
+
return callback(new Error(`Proxy CONNECT failed with HTTP ${res.statusCode}`))
|
|
57
|
+
}
|
|
58
|
+
const tlsSocket = tls.connect({
|
|
59
|
+
host: opts.host,
|
|
60
|
+
socket,
|
|
61
|
+
servername: opts.servername || opts.host,
|
|
62
62
|
})
|
|
63
|
+
callback(null, tlsSocket)
|
|
64
|
+
})
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
connectReq.on('error', (err) => callback(err))
|
|
67
|
+
connectReq.end()
|
|
68
|
+
}
|
|
69
|
+
return agent
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -627,4 +629,4 @@ export class TelegramGateway extends Service {
|
|
|
627
629
|
dispose() {
|
|
628
630
|
void this.stop()
|
|
629
631
|
}
|
|
630
|
-
}
|
|
632
|
+
}
|
package/lib/tunnel-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { request as httpRequest } from 'node:http';
|
|
|
4
4
|
import { connect as netConnect } from 'node:net';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { gzip as gzipCallback } from 'node:zlib';
|
|
7
|
+
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
|
7
8
|
import { stripSessionProjections } from './session-strip.js';
|
|
8
9
|
|
|
9
10
|
const gzipAsync = promisify(gzipCallback);
|
|
@@ -22,7 +23,7 @@ const GZIP_THRESHOLD = 102400; // 100KB
|
|
|
22
23
|
const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
|
|
23
24
|
|
|
24
25
|
export class CustomTunnelClient {
|
|
25
|
-
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
|
|
26
|
+
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger, sseStreaming = false }) {
|
|
26
27
|
this.serverUrl = serverUrl;
|
|
27
28
|
this.accessToken = accessToken;
|
|
28
29
|
this.localPort = localPort;
|
|
@@ -30,6 +31,7 @@ export class CustomTunnelClient {
|
|
|
30
31
|
this.signal = signal;
|
|
31
32
|
this.onStateChange = onStateChange;
|
|
32
33
|
this.logger = logger;
|
|
34
|
+
this.sseStreaming = sseStreaming;
|
|
33
35
|
this.ws = null;
|
|
34
36
|
this.publicUrl = null;
|
|
35
37
|
this.connected = false;
|
|
@@ -161,8 +163,33 @@ export class CustomTunnelClient {
|
|
|
161
163
|
const chunks = [];
|
|
162
164
|
|
|
163
165
|
if (isSSE) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
if (this.sseStreaming) {
|
|
167
|
+
// ── 流式模式(需服务端支持 response-start/chunk/end 协议)──
|
|
168
|
+
// 逐 chunk 转发,SSE 长连接持续保持,浏览器端不会断连重连
|
|
169
|
+
const respHeaders = Object.fromEntries(
|
|
170
|
+
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
171
|
+
);
|
|
172
|
+
this._sendMessage({
|
|
173
|
+
type: 'response-start', requestId,
|
|
174
|
+
statusCode: res.statusCode, headers: respHeaders,
|
|
175
|
+
});
|
|
176
|
+
res.on('data', (c) => {
|
|
177
|
+
this._sendMessage({
|
|
178
|
+
type: 'response-chunk', requestId,
|
|
179
|
+
body: c.toString('base64'),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
res.on('error', () => {
|
|
183
|
+
this._sendMessage({ type: 'response-end', requestId });
|
|
184
|
+
});
|
|
185
|
+
res.on('end', () => {
|
|
186
|
+
this._sendMessage({ type: 'response-end', requestId });
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 截断模式(默认,兼容老服务端)──
|
|
192
|
+
// 收集初始数据后立即返回,避免 SSE 永不 end 导致隧道服务器超时 504
|
|
166
193
|
let sseSent = false;
|
|
167
194
|
const sseTimer = setTimeout(() => {
|
|
168
195
|
if (sseSent) return;
|
|
@@ -257,17 +284,22 @@ export class CustomTunnelClient {
|
|
|
257
284
|
let bodyBuf = Buffer.concat(chunks);
|
|
258
285
|
|
|
259
286
|
// 剥离 session.list / session.history 的 contextHeaders 投影(无 UI 读取的大字段)
|
|
287
|
+
// 传入 content-encoding 以便 stripSessionProjections 先 gunzip 再解析
|
|
260
288
|
if (res.statusCode === 200) {
|
|
261
|
-
const { body, stripped } = stripSessionProjections(path, bodyBuf);
|
|
289
|
+
const { body, stripped } = stripSessionProjections(path, bodyBuf, res.headers['content-encoding']);
|
|
262
290
|
if (stripped) {
|
|
263
291
|
bodyBuf = body;
|
|
292
|
+
// 已修改 body,清除原始压缩编码标记,让下方 gzip 逻辑重新压缩
|
|
293
|
+
delete respHeaders['content-encoding'];
|
|
264
294
|
respHeaders['content-length'] = String(bodyBuf.length);
|
|
265
295
|
}
|
|
266
296
|
}
|
|
267
297
|
|
|
268
298
|
// 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
|
|
269
299
|
const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
|
|
270
|
-
|
|
300
|
+
// 注意:alreadyEncoded 从 respHeaders 读取(而非 res.headers),
|
|
301
|
+
// 因为上面的剥离逻辑可能已经删除了 respHeaders['content-encoding']
|
|
302
|
+
const alreadyEncoded = String(respHeaders['content-encoding'] ?? res.headers['content-encoding'] ?? '').toLowerCase();
|
|
271
303
|
const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
|
|
272
304
|
if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
|
|
273
305
|
gzipAsync(bodyBuf).then((zipped) => {
|
|
@@ -316,9 +348,15 @@ export class CustomTunnelClient {
|
|
|
316
348
|
let headerBuf = '';
|
|
317
349
|
let upgraded = false;
|
|
318
350
|
|
|
351
|
+
// WebSocket 帧解析缓冲区 — 拦截 Ping (0x9) 自动回复 Pong (0xA)。
|
|
352
|
+
// DSH API Gateway 的 /api/remote.mux WebSocket 每 2s 发 Ping,连续 2 次没收到
|
|
353
|
+
// Pong 就 terminate。隧道用裸 TCP 转发,Pong 往返延迟可能超时,本地直接回复最可靠。
|
|
354
|
+
let wsFrameBuf = Buffer.alloc(0);
|
|
355
|
+
|
|
319
356
|
sock.on('data', (chunk) => {
|
|
320
357
|
if (upgraded) {
|
|
321
|
-
|
|
358
|
+
wsFrameBuf = Buffer.concat([wsFrameBuf, chunk]);
|
|
359
|
+
wsFrameBuf = this._processWsFrames(wsId, wsFrameBuf, sock);
|
|
322
360
|
return;
|
|
323
361
|
}
|
|
324
362
|
headerBuf += chunk.toString('binary');
|
|
@@ -326,6 +364,7 @@ export class CustomTunnelClient {
|
|
|
326
364
|
if (sep === -1) return;
|
|
327
365
|
|
|
328
366
|
upgraded = true;
|
|
367
|
+
const statusLine = headerBuf.slice(0, headerBuf.indexOf('\r\n'));
|
|
329
368
|
const replyHeaders = {};
|
|
330
369
|
const headerLines = headerBuf.slice(0, sep).split('\r\n');
|
|
331
370
|
for (let i = 1; i < headerLines.length; i++) {
|
|
@@ -335,7 +374,20 @@ export class CustomTunnelClient {
|
|
|
335
374
|
headerLines[i].slice(ci + 1).trim();
|
|
336
375
|
}
|
|
337
376
|
}
|
|
338
|
-
|
|
377
|
+
|
|
378
|
+
// 解析状态码,传递给服务端以便正确转发(非 101 时浏览器能看到真实错误)
|
|
379
|
+
const statusMatch = /^HTTP\/1\.\d\s+(\d+)\s*(.*)/.exec(statusLine);
|
|
380
|
+
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 101;
|
|
381
|
+
const statusMessage = statusMatch ? statusMatch[2] : '';
|
|
382
|
+
|
|
383
|
+
if (statusCode === 101) {
|
|
384
|
+
this._sendMessage({ type: 'ws-accept', wsId, statusCode, replyHeaders });
|
|
385
|
+
} else {
|
|
386
|
+
// 非 101 响应:转发状态码和头,让浏览器看到真实错误(如 401)
|
|
387
|
+
this._sendMessage({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
|
|
388
|
+
sock.destroy();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
339
391
|
|
|
340
392
|
// 握手后紧跟的帧数据
|
|
341
393
|
const rest = headerBuf.slice(sep + 4);
|
|
@@ -355,6 +407,70 @@ export class CustomTunnelClient {
|
|
|
355
407
|
});
|
|
356
408
|
}
|
|
357
409
|
|
|
410
|
+
// ── WebSocket 帧解析 ─────────────────────────────────────────────────────
|
|
411
|
+
// 在裸 TCP 层面解析 WebSocket 帧,拦截 Ping (0x9) 自动回复 Pong (0xA),
|
|
412
|
+
// 其余帧原样转发。DSH API Gateway 每 2s 发 Ping,2 次没 Pong 就 terminate。
|
|
413
|
+
_processWsFrames(wsId, buf, sock) {
|
|
414
|
+
while (buf.length >= 2) {
|
|
415
|
+
const b0 = buf[0];
|
|
416
|
+
const b1 = buf[1];
|
|
417
|
+
const opcode = b0 & 0x0f;
|
|
418
|
+
const masked = b1 & 0x80;
|
|
419
|
+
let payloadLen = b1 & 0x7f;
|
|
420
|
+
let offset = 2;
|
|
421
|
+
|
|
422
|
+
if (payloadLen === 126) {
|
|
423
|
+
if (buf.length < 4) break;
|
|
424
|
+
payloadLen = buf.readUInt16BE(2);
|
|
425
|
+
offset = 4;
|
|
426
|
+
} else if (payloadLen === 127) {
|
|
427
|
+
if (buf.length < 10) break;
|
|
428
|
+
payloadLen = Number(buf.readBigUInt64BE(2));
|
|
429
|
+
offset = 10;
|
|
430
|
+
}
|
|
431
|
+
if (masked) offset += 4;
|
|
432
|
+
if (buf.length < offset + payloadLen) break;
|
|
433
|
+
|
|
434
|
+
const frameEnd = offset + payloadLen;
|
|
435
|
+
const frameBytes = buf.subarray(0, frameEnd);
|
|
436
|
+
|
|
437
|
+
if (opcode === 0x9) {
|
|
438
|
+
// Ping → 自动回复 Pong(同 payload,必须 mask,因为这是 client→server 方向)
|
|
439
|
+
// WebSocket 协议规定客户端→服务端的帧必须加 mask,否则服务端 ws 库会判定
|
|
440
|
+
// 协议错误(1002)并关闭连接。
|
|
441
|
+
const payload = buf.subarray(offset, frameEnd);
|
|
442
|
+
const mask = cryptoRandomBytes(4);
|
|
443
|
+
const maskedPayload = Buffer.alloc(payload.length);
|
|
444
|
+
for (let i = 0; i < payload.length; i++) {
|
|
445
|
+
maskedPayload[i] = payload[i] ^ mask[i % 4];
|
|
446
|
+
}
|
|
447
|
+
if (payload.length < 126) {
|
|
448
|
+
const hdr = Buffer.alloc(6);
|
|
449
|
+
hdr[0] = 0x8a; // fin + opcode 0xA (pong)
|
|
450
|
+
hdr[1] = 0x80 | payload.length; // masked + length
|
|
451
|
+
mask.copy(hdr, 2);
|
|
452
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
453
|
+
} else if (payload.length < 65536) {
|
|
454
|
+
const hdr = Buffer.alloc(8);
|
|
455
|
+
hdr[0] = 0x8a; hdr[1] = 0x80 | 126;
|
|
456
|
+
hdr.writeUInt16BE(payload.length, 2);
|
|
457
|
+
mask.copy(hdr, 4);
|
|
458
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
459
|
+
} else {
|
|
460
|
+
const hdr = Buffer.alloc(14);
|
|
461
|
+
hdr[0] = 0x8a; hdr[1] = 0x80 | 127;
|
|
462
|
+
hdr.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
463
|
+
mask.copy(hdr, 10);
|
|
464
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
this._sendMessage({ type: 'ws-frame', wsId, data: frameBytes.toString('base64') });
|
|
468
|
+
}
|
|
469
|
+
buf = buf.subarray(frameEnd);
|
|
470
|
+
}
|
|
471
|
+
return buf;
|
|
472
|
+
}
|
|
473
|
+
|
|
358
474
|
_handleWsFrame(msg) {
|
|
359
475
|
const sock = this.localWsSockets.get(msg.wsId);
|
|
360
476
|
if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.4",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
5
|
+
"releaseNotes": "【v2.10.4】\n• 修复:全新安装(未设置任何密码)时远程访问被误锁,需输入『管理密码』(任意输入皆可通过)才可进入——现无密码时远程免锁直进,并明确提示先设置访问密码\n• 修复:Telegram 代理在 Node ≥ 24 下不生效(createConnection 构造参数被忽略,改为构造后赋值)\n• 修复:移动端点击 workspace 分组名误关抽屉、自建隧道 gzip 会话剥离失效、目录弹窗深色模式适配\n• 新功能:自建隧道 SSE 流式传输 + WebSocket Ping/Pong 保活;远程/移动端禁用下拉刷新防误触",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|