@qzsy/dsh-bridge 2.18.0 → 3.0.1
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 +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/tunnel-client.mjs
DELETED
|
@@ -1,537 +0,0 @@
|
|
|
1
|
-
// DSH Bridge - Custom Tunnel Client
|
|
2
|
-
import { WebSocket } from 'ws';
|
|
3
|
-
import { request as httpRequest } from 'node:http';
|
|
4
|
-
import { connect as netConnect } from 'node:net';
|
|
5
|
-
import { promisify } from 'node:util';
|
|
6
|
-
import { gzip as gzipCallback } from 'node:zlib';
|
|
7
|
-
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
|
8
|
-
import { stripSessionProjections, isSessionProjectionPath } from './session-strip.js';
|
|
9
|
-
|
|
10
|
-
const gzipAsync = promisify(gzipCallback);
|
|
11
|
-
|
|
12
|
-
const HEARTBEAT_INTERVAL = 30000;
|
|
13
|
-
const RECONNECT_DELAY = 5000;
|
|
14
|
-
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
15
|
-
|
|
16
|
-
// 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
|
|
17
|
-
// 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
|
|
18
|
-
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
|
|
19
|
-
|
|
20
|
-
// 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
|
|
21
|
-
const GZIP_THRESHOLD = 102400; // 100KB
|
|
22
|
-
// 可压缩的 content-type 前缀
|
|
23
|
-
const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
|
|
24
|
-
|
|
25
|
-
export class CustomTunnelClient {
|
|
26
|
-
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger, sseStreaming = false }) {
|
|
27
|
-
this.serverUrl = serverUrl;
|
|
28
|
-
this.accessToken = accessToken;
|
|
29
|
-
this.localPort = localPort;
|
|
30
|
-
this.internalTunnelSecret = internalTunnelSecret;
|
|
31
|
-
this.signal = signal;
|
|
32
|
-
this.onStateChange = onStateChange;
|
|
33
|
-
this.logger = logger;
|
|
34
|
-
this.sseStreaming = sseStreaming;
|
|
35
|
-
this.ws = null;
|
|
36
|
-
this.publicUrl = null;
|
|
37
|
-
this.connected = false;
|
|
38
|
-
this.disconnecting = false;
|
|
39
|
-
this.reconnectAttempts = 0;
|
|
40
|
-
this.reconnectTimer = null;
|
|
41
|
-
this.heartbeatTimer = null;
|
|
42
|
-
this.localWsSockets = new Map(); // wsId -> net.Socket
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async connect() {
|
|
46
|
-
if (this.connected) return;
|
|
47
|
-
this._setState('connecting', 'Connecting to tunnel server...');
|
|
48
|
-
try {
|
|
49
|
-
await this._connectWebSocket();
|
|
50
|
-
this._startHeartbeat();
|
|
51
|
-
this.reconnectAttempts = 0;
|
|
52
|
-
this._setState('ready', 'Tunnel established');
|
|
53
|
-
} catch (err) {
|
|
54
|
-
this._setState('error', err.message);
|
|
55
|
-
throw err;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
_connectWebSocket() {
|
|
60
|
-
return new Promise((resolve, reject) => {
|
|
61
|
-
if (this.signal?.aborted) return reject(new Error('Aborted'));
|
|
62
|
-
|
|
63
|
-
const url = new URL(this.serverUrl);
|
|
64
|
-
url.searchParams.set('token', this.accessToken);
|
|
65
|
-
|
|
66
|
-
this.ws = new WebSocket(url.toString(), {
|
|
67
|
-
handshakeTimeout: 10000,
|
|
68
|
-
perMessageDeflate: {
|
|
69
|
-
clientNoContextTakeover: true,
|
|
70
|
-
serverNoContextTakeover: true,
|
|
71
|
-
clientMaxWindowBits: 15,
|
|
72
|
-
serverMaxWindowBits: 15,
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
|
|
77
|
-
this.signal?.addEventListener('abort', onAbort);
|
|
78
|
-
|
|
79
|
-
this.ws.on('open', () => {
|
|
80
|
-
this.signal?.removeEventListener('abort', onAbort);
|
|
81
|
-
this.logger?.info('Tunnel WebSocket connected');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
this.ws.on('message', (data) => this._handleMessage(data));
|
|
85
|
-
|
|
86
|
-
this.ws.on('close', (code, reason) => {
|
|
87
|
-
this.connected = false;
|
|
88
|
-
this._stopHeartbeat();
|
|
89
|
-
this._cleanupLocalWs();
|
|
90
|
-
if (!this.signal?.aborted) {
|
|
91
|
-
this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
|
|
92
|
-
this._scheduleReconnect();
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
this.ws.on('error', (err) => {
|
|
97
|
-
this.logger?.error('Tunnel WebSocket error: %s', err.message);
|
|
98
|
-
if (!this.connected) {
|
|
99
|
-
this.signal?.removeEventListener('abort', onAbort);
|
|
100
|
-
reject(err);
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const readyHandler = (data) => {
|
|
105
|
-
try {
|
|
106
|
-
const msg = JSON.parse(data.toString());
|
|
107
|
-
if (msg.type === 'ready' && msg.publicUrl) {
|
|
108
|
-
this.publicUrl = msg.publicUrl;
|
|
109
|
-
this.connected = true;
|
|
110
|
-
this.ws.off('message', readyHandler);
|
|
111
|
-
this.signal?.removeEventListener('abort', onAbort);
|
|
112
|
-
this.logger?.info('Tunnel ready: %s', this.publicUrl);
|
|
113
|
-
resolve();
|
|
114
|
-
}
|
|
115
|
-
} catch {}
|
|
116
|
-
};
|
|
117
|
-
this.ws.on('message', readyHandler);
|
|
118
|
-
|
|
119
|
-
setTimeout(() => {
|
|
120
|
-
if (!this.connected) {
|
|
121
|
-
this.signal?.removeEventListener('abort', onAbort);
|
|
122
|
-
this.ws?.terminate();
|
|
123
|
-
reject(new Error('Connection timeout'));
|
|
124
|
-
}
|
|
125
|
-
}, 15000);
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
_handleMessage(data) {
|
|
130
|
-
try {
|
|
131
|
-
const msg = JSON.parse(data.toString());
|
|
132
|
-
if (msg.type === 'request') this._handleHttpRequest(msg);
|
|
133
|
-
else if (msg.type === 'ws-open') this._handleWsOpen(msg);
|
|
134
|
-
else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
|
|
135
|
-
else if (msg.type === 'ws-close') this._handleWsClose(msg);
|
|
136
|
-
// pong: ignore
|
|
137
|
-
} catch (err) {
|
|
138
|
-
this.logger?.error('Failed to parse tunnel message: %s', err.message);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ── HTTP 请求代理 ─────────────────────────────────────────────────────────
|
|
143
|
-
_handleHttpRequest(msg) {
|
|
144
|
-
const { requestId, method, path, headers } = msg;
|
|
145
|
-
const SKIP = new Set(['transfer-encoding','connection','keep-alive',
|
|
146
|
-
'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
|
|
147
|
-
const safeHeaders = Object.fromEntries(
|
|
148
|
-
Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
|
|
152
|
-
if (this.internalTunnelSecret) {
|
|
153
|
-
reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const req = httpRequest({
|
|
157
|
-
host: '127.0.0.1', port: this.localPort,
|
|
158
|
-
method, path: path || '/',
|
|
159
|
-
headers: reqHeaders,
|
|
160
|
-
}, (res) => {
|
|
161
|
-
const contentType = String(res.headers['content-type'] ?? '');
|
|
162
|
-
const isSSE = contentType.includes('text/event-stream');
|
|
163
|
-
const chunks = [];
|
|
164
|
-
|
|
165
|
-
if (isSSE) {
|
|
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
|
|
193
|
-
let sseSent = false;
|
|
194
|
-
const sseTimer = setTimeout(() => {
|
|
195
|
-
if (sseSent) return;
|
|
196
|
-
sseSent = true;
|
|
197
|
-
const respHeaders = Object.fromEntries(
|
|
198
|
-
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
199
|
-
);
|
|
200
|
-
this._sendMessage({
|
|
201
|
-
type: 'response', requestId,
|
|
202
|
-
statusCode: res.statusCode, headers: respHeaders,
|
|
203
|
-
body: Buffer.concat(chunks).toString('base64'),
|
|
204
|
-
});
|
|
205
|
-
res.destroy();
|
|
206
|
-
}, 500);
|
|
207
|
-
|
|
208
|
-
res.on('data', (c) => {
|
|
209
|
-
if (sseSent) return;
|
|
210
|
-
chunks.push(c);
|
|
211
|
-
// 收到初始数据后立即发送(不等超时)
|
|
212
|
-
if (chunks.length >= 2) {
|
|
213
|
-
clearTimeout(sseTimer);
|
|
214
|
-
sseSent = true;
|
|
215
|
-
const respHeaders = Object.fromEntries(
|
|
216
|
-
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
217
|
-
);
|
|
218
|
-
this._sendMessage({
|
|
219
|
-
type: 'response', requestId,
|
|
220
|
-
statusCode: res.statusCode, headers: respHeaders,
|
|
221
|
-
body: Buffer.concat(chunks).toString('base64'),
|
|
222
|
-
});
|
|
223
|
-
res.destroy();
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
res.on('error', () => {
|
|
227
|
-
if (!sseSent) {
|
|
228
|
-
clearTimeout(sseTimer);
|
|
229
|
-
this._sendMessage({
|
|
230
|
-
type: 'response', requestId, statusCode: 502,
|
|
231
|
-
headers: { 'content-type': 'text/plain' },
|
|
232
|
-
body: Buffer.from('Response Error').toString('base64'),
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
res.on('end', () => {
|
|
237
|
-
if (!sseSent) {
|
|
238
|
-
clearTimeout(sseTimer);
|
|
239
|
-
sseSent = true;
|
|
240
|
-
const respHeaders = Object.fromEntries(
|
|
241
|
-
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
242
|
-
);
|
|
243
|
-
this._sendMessage({
|
|
244
|
-
type: 'response', requestId,
|
|
245
|
-
statusCode: res.statusCode, headers: respHeaders,
|
|
246
|
-
body: Buffer.concat(chunks).toString('base64'),
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
let responded = false;
|
|
254
|
-
const sendResponse = (statusCode, headers, body) => {
|
|
255
|
-
if (responded) return;
|
|
256
|
-
responded = true;
|
|
257
|
-
this._sendMessage({
|
|
258
|
-
type: 'response', requestId, statusCode, headers,
|
|
259
|
-
body: Buffer.from(body).toString('base64'),
|
|
260
|
-
});
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
let total = 0;
|
|
264
|
-
const cleanPath = String(path || '').split('?')[0];
|
|
265
|
-
// session 投影端点(DSH 真实斜杠路径 session/list、session/history,兼容点号旧写法)
|
|
266
|
-
// 会在 end 后剥离 contextHeaders 等大字段,跳过 data 阶段的大小检查。
|
|
267
|
-
// 判定与 stripSessionProjections 共用同一谓词,避免两处路径口径漂移。
|
|
268
|
-
const skipSizeCheck = isSessionProjectionPath(cleanPath);
|
|
269
|
-
res.on('data', (c) => {
|
|
270
|
-
chunks.push(c);
|
|
271
|
-
total += c.length;
|
|
272
|
-
if (!skipSizeCheck && total > MAX_RESPONSE_BYTES) {
|
|
273
|
-
sendResponse(502, { 'content-type': 'text/plain' },
|
|
274
|
-
`Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
|
|
275
|
-
res.destroy();
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
res.on('error', () => {
|
|
279
|
-
sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
|
|
280
|
-
});
|
|
281
|
-
res.on('end', () => {
|
|
282
|
-
if (responded) return;
|
|
283
|
-
const respHeaders = Object.fromEntries(
|
|
284
|
-
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
285
|
-
);
|
|
286
|
-
let bodyBuf = Buffer.concat(chunks);
|
|
287
|
-
|
|
288
|
-
// 剥离 session/list、session/history(兼容点号旧路径)的 contextHeaders 投影(无 UI 读取的大字段)
|
|
289
|
-
// 传入 content-encoding 以便 stripSessionProjections 先 gunzip 再解析
|
|
290
|
-
if (res.statusCode === 200) {
|
|
291
|
-
const { body, stripped } = stripSessionProjections(path, bodyBuf, res.headers['content-encoding']);
|
|
292
|
-
if (stripped) {
|
|
293
|
-
bodyBuf = body;
|
|
294
|
-
// 已修改 body,清除原始压缩编码标记,让下方 gzip 逻辑重新压缩
|
|
295
|
-
delete respHeaders['content-encoding'];
|
|
296
|
-
respHeaders['content-length'] = String(bodyBuf.length);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
|
|
301
|
-
const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
|
|
302
|
-
// 注意:alreadyEncoded 从 respHeaders 读取(而非 res.headers),
|
|
303
|
-
// 因为上面的剥离逻辑可能已经删除了 respHeaders['content-encoding']
|
|
304
|
-
const alreadyEncoded = String(respHeaders['content-encoding'] ?? res.headers['content-encoding'] ?? '').toLowerCase();
|
|
305
|
-
const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
|
|
306
|
-
if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
|
|
307
|
-
gzipAsync(bodyBuf).then((zipped) => {
|
|
308
|
-
respHeaders['content-encoding'] = 'gzip';
|
|
309
|
-
respHeaders['content-length'] = String(zipped.length);
|
|
310
|
-
sendResponse(res.statusCode, respHeaders, zipped);
|
|
311
|
-
}).catch(() => {
|
|
312
|
-
sendResponse(res.statusCode, respHeaders, bodyBuf);
|
|
313
|
-
});
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
sendResponse(res.statusCode, respHeaders, bodyBuf);
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
req.on('error', err => {
|
|
321
|
-
this._sendMessage({ type: 'response', requestId, statusCode: 502,
|
|
322
|
-
headers: { 'content-type': 'text/plain' },
|
|
323
|
-
body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
|
|
324
|
-
});
|
|
325
|
-
if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
|
|
326
|
-
req.end();
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ── WebSocket 升级代理 ────────────────────────────────────────────────────
|
|
330
|
-
// 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
|
|
331
|
-
_handleWsOpen(msg) {
|
|
332
|
-
const { wsId, path, headers } = msg;
|
|
333
|
-
|
|
334
|
-
const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
|
|
335
|
-
this.localWsSockets.set(wsId, sock);
|
|
336
|
-
|
|
337
|
-
// 构造 HTTP Upgrade 请求
|
|
338
|
-
const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
|
|
339
|
-
delete reqHeaders['proxy-connection'];
|
|
340
|
-
delete reqHeaders['proxy-authorization'];
|
|
341
|
-
if (this.internalTunnelSecret) {
|
|
342
|
-
reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const lines = [`GET ${path || '/'} HTTP/1.1`];
|
|
346
|
-
for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
|
|
347
|
-
lines.push('', '');
|
|
348
|
-
sock.write(lines.join('\r\n'));
|
|
349
|
-
|
|
350
|
-
let headerBuf = '';
|
|
351
|
-
let upgraded = false;
|
|
352
|
-
|
|
353
|
-
// WebSocket 帧解析缓冲区 — 拦截 Ping (0x9) 自动回复 Pong (0xA)。
|
|
354
|
-
// DSH API Gateway 的 /api/remote.mux WebSocket 每 2s 发 Ping,连续 2 次没收到
|
|
355
|
-
// Pong 就 terminate。隧道用裸 TCP 转发,Pong 往返延迟可能超时,本地直接回复最可靠。
|
|
356
|
-
let wsFrameBuf = Buffer.alloc(0);
|
|
357
|
-
|
|
358
|
-
sock.on('data', (chunk) => {
|
|
359
|
-
if (upgraded) {
|
|
360
|
-
wsFrameBuf = Buffer.concat([wsFrameBuf, chunk]);
|
|
361
|
-
wsFrameBuf = this._processWsFrames(wsId, wsFrameBuf, sock);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
headerBuf += chunk.toString('binary');
|
|
365
|
-
const sep = headerBuf.indexOf('\r\n\r\n');
|
|
366
|
-
if (sep === -1) return;
|
|
367
|
-
|
|
368
|
-
upgraded = true;
|
|
369
|
-
const statusLine = headerBuf.slice(0, headerBuf.indexOf('\r\n'));
|
|
370
|
-
const replyHeaders = {};
|
|
371
|
-
const headerLines = headerBuf.slice(0, sep).split('\r\n');
|
|
372
|
-
for (let i = 1; i < headerLines.length; i++) {
|
|
373
|
-
const ci = headerLines[i].indexOf(':');
|
|
374
|
-
if (ci > 0) {
|
|
375
|
-
replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
|
|
376
|
-
headerLines[i].slice(ci + 1).trim();
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// 解析状态码,传递给服务端以便正确转发(非 101 时浏览器能看到真实错误)
|
|
381
|
-
const statusMatch = /^HTTP\/1\.\d\s+(\d+)\s*(.*)/.exec(statusLine);
|
|
382
|
-
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 101;
|
|
383
|
-
const statusMessage = statusMatch ? statusMatch[2] : '';
|
|
384
|
-
|
|
385
|
-
if (statusCode === 101) {
|
|
386
|
-
this._sendMessage({ type: 'ws-accept', wsId, statusCode, replyHeaders });
|
|
387
|
-
} else {
|
|
388
|
-
// 非 101 响应:转发状态码和头,让浏览器看到真实错误(如 401)
|
|
389
|
-
this._sendMessage({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
|
|
390
|
-
sock.destroy();
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// 握手后紧跟的帧数据
|
|
395
|
-
const rest = headerBuf.slice(sep + 4);
|
|
396
|
-
if (rest.length > 0) {
|
|
397
|
-
this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
sock.on('close', () => {
|
|
402
|
-
this._sendMessage({ type: 'ws-close', wsId });
|
|
403
|
-
this.localWsSockets.delete(wsId);
|
|
404
|
-
});
|
|
405
|
-
sock.on('error', (err) => {
|
|
406
|
-
this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
|
|
407
|
-
this._sendMessage({ type: 'ws-close', wsId });
|
|
408
|
-
this.localWsSockets.delete(wsId);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ── WebSocket 帧解析 ─────────────────────────────────────────────────────
|
|
413
|
-
// 在裸 TCP 层面解析 WebSocket 帧,拦截 Ping (0x9) 自动回复 Pong (0xA),
|
|
414
|
-
// 其余帧原样转发。DSH API Gateway 每 2s 发 Ping,2 次没 Pong 就 terminate。
|
|
415
|
-
_processWsFrames(wsId, buf, sock) {
|
|
416
|
-
while (buf.length >= 2) {
|
|
417
|
-
const b0 = buf[0];
|
|
418
|
-
const b1 = buf[1];
|
|
419
|
-
const opcode = b0 & 0x0f;
|
|
420
|
-
const masked = b1 & 0x80;
|
|
421
|
-
let payloadLen = b1 & 0x7f;
|
|
422
|
-
let offset = 2;
|
|
423
|
-
|
|
424
|
-
if (payloadLen === 126) {
|
|
425
|
-
if (buf.length < 4) break;
|
|
426
|
-
payloadLen = buf.readUInt16BE(2);
|
|
427
|
-
offset = 4;
|
|
428
|
-
} else if (payloadLen === 127) {
|
|
429
|
-
if (buf.length < 10) break;
|
|
430
|
-
payloadLen = Number(buf.readBigUInt64BE(2));
|
|
431
|
-
offset = 10;
|
|
432
|
-
}
|
|
433
|
-
if (masked) offset += 4;
|
|
434
|
-
if (buf.length < offset + payloadLen) break;
|
|
435
|
-
|
|
436
|
-
const frameEnd = offset + payloadLen;
|
|
437
|
-
const frameBytes = buf.subarray(0, frameEnd);
|
|
438
|
-
|
|
439
|
-
if (opcode === 0x9) {
|
|
440
|
-
// Ping → 自动回复 Pong(同 payload,必须 mask,因为这是 client→server 方向)
|
|
441
|
-
// WebSocket 协议规定客户端→服务端的帧必须加 mask,否则服务端 ws 库会判定
|
|
442
|
-
// 协议错误(1002)并关闭连接。
|
|
443
|
-
const payload = buf.subarray(offset, frameEnd);
|
|
444
|
-
const mask = cryptoRandomBytes(4);
|
|
445
|
-
const maskedPayload = Buffer.alloc(payload.length);
|
|
446
|
-
for (let i = 0; i < payload.length; i++) {
|
|
447
|
-
maskedPayload[i] = payload[i] ^ mask[i % 4];
|
|
448
|
-
}
|
|
449
|
-
if (payload.length < 126) {
|
|
450
|
-
const hdr = Buffer.alloc(6);
|
|
451
|
-
hdr[0] = 0x8a; // fin + opcode 0xA (pong)
|
|
452
|
-
hdr[1] = 0x80 | payload.length; // masked + length
|
|
453
|
-
mask.copy(hdr, 2);
|
|
454
|
-
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
455
|
-
} else if (payload.length < 65536) {
|
|
456
|
-
const hdr = Buffer.alloc(8);
|
|
457
|
-
hdr[0] = 0x8a; hdr[1] = 0x80 | 126;
|
|
458
|
-
hdr.writeUInt16BE(payload.length, 2);
|
|
459
|
-
mask.copy(hdr, 4);
|
|
460
|
-
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
461
|
-
} else {
|
|
462
|
-
const hdr = Buffer.alloc(14);
|
|
463
|
-
hdr[0] = 0x8a; hdr[1] = 0x80 | 127;
|
|
464
|
-
hdr.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
465
|
-
mask.copy(hdr, 10);
|
|
466
|
-
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
467
|
-
}
|
|
468
|
-
} else {
|
|
469
|
-
this._sendMessage({ type: 'ws-frame', wsId, data: frameBytes.toString('base64') });
|
|
470
|
-
}
|
|
471
|
-
buf = buf.subarray(frameEnd);
|
|
472
|
-
}
|
|
473
|
-
return buf;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
_handleWsFrame(msg) {
|
|
477
|
-
const sock = this.localWsSockets.get(msg.wsId);
|
|
478
|
-
if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
_handleWsClose(msg) {
|
|
482
|
-
const sock = this.localWsSockets.get(msg.wsId);
|
|
483
|
-
if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
_cleanupLocalWs() {
|
|
487
|
-
for (const [, sock] of this.localWsSockets) sock.destroy();
|
|
488
|
-
this.localWsSockets.clear();
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ── 工具方法 ──────────────────────────────────────────────────────────────
|
|
492
|
-
_sendMessage(msg) {
|
|
493
|
-
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
_startHeartbeat() {
|
|
497
|
-
this._stopHeartbeat();
|
|
498
|
-
this.heartbeatTimer = setInterval(() => {
|
|
499
|
-
if (this.connected) this._sendMessage({ type: 'ping' });
|
|
500
|
-
}, HEARTBEAT_INTERVAL);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
_stopHeartbeat() {
|
|
504
|
-
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
_scheduleReconnect() {
|
|
508
|
-
if (this.signal?.aborted || this.disconnecting) return;
|
|
509
|
-
if (this.reconnectTimer) return;
|
|
510
|
-
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
511
|
-
this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
this.reconnectAttempts++;
|
|
515
|
-
const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
|
|
516
|
-
this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
517
|
-
this.reconnectTimer = setTimeout(() => {
|
|
518
|
-
this.reconnectTimer = null;
|
|
519
|
-
this.connect().catch(() => {});
|
|
520
|
-
}, delay);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
_setState(phase, detail) {
|
|
524
|
-
if (this.onStateChange) this.onStateChange({ phase, detail });
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
disconnect() {
|
|
528
|
-
this.disconnecting = true;
|
|
529
|
-
this._stopHeartbeat();
|
|
530
|
-
this._cleanupLocalWs();
|
|
531
|
-
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
532
|
-
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
533
|
-
this.connected = false;
|
|
534
|
-
this.publicUrl = null;
|
|
535
|
-
this.logger?.info('Tunnel disconnected');
|
|
536
|
-
}
|
|
537
|
-
}
|