@wenbin_wb/dsh-bridge 1.0.5 → 1.0.6
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/lib/tunnel-client.mjs +136 -156
- package/package.json +1 -1
package/lib/tunnel-client.mjs
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
// DSH Bridge - Custom Tunnel Client
|
|
2
|
-
// WebSocket-based reverse tunnel with automatic reconnection and health monitoring
|
|
3
|
-
|
|
4
2
|
import { WebSocket } from 'ws';
|
|
5
3
|
import { request as httpRequest } from 'node:http';
|
|
4
|
+
import { connect as netConnect } from 'node:net';
|
|
6
5
|
|
|
7
|
-
const HEARTBEAT_INTERVAL = 30000;
|
|
8
|
-
const RECONNECT_DELAY = 5000;
|
|
6
|
+
const HEARTBEAT_INTERVAL = 30000;
|
|
7
|
+
const RECONNECT_DELAY = 5000;
|
|
9
8
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
* Custom tunnel client with production-grade features:
|
|
13
|
-
* - Automatic reconnection with exponential backoff
|
|
14
|
-
* - Heartbeat monitoring
|
|
15
|
-
* - Request multiplexing
|
|
16
|
-
* - Graceful shutdown
|
|
17
|
-
*/
|
|
18
10
|
export class CustomTunnelClient {
|
|
19
11
|
constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
|
|
20
12
|
this.serverUrl = serverUrl;
|
|
@@ -23,7 +15,6 @@ export class CustomTunnelClient {
|
|
|
23
15
|
this.signal = signal;
|
|
24
16
|
this.onStateChange = onStateChange;
|
|
25
17
|
this.logger = logger;
|
|
26
|
-
|
|
27
18
|
this.ws = null;
|
|
28
19
|
this.publicUrl = null;
|
|
29
20
|
this.connected = false;
|
|
@@ -32,14 +23,12 @@ export class CustomTunnelClient {
|
|
|
32
23
|
this.reconnectTimer = null;
|
|
33
24
|
this.heartbeatTimer = null;
|
|
34
25
|
this.pendingRequests = new Map();
|
|
35
|
-
this.
|
|
26
|
+
this.localWsSockets = new Map(); // wsId -> net.Socket
|
|
36
27
|
}
|
|
37
|
-
|
|
28
|
+
|
|
38
29
|
async connect() {
|
|
39
30
|
if (this.connected) return;
|
|
40
|
-
|
|
41
31
|
this._setState('connecting', 'Connecting to tunnel server...');
|
|
42
|
-
|
|
43
32
|
try {
|
|
44
33
|
await this._connectWebSocket();
|
|
45
34
|
this._startHeartbeat();
|
|
@@ -50,57 +39,47 @@ export class CustomTunnelClient {
|
|
|
50
39
|
throw err;
|
|
51
40
|
}
|
|
52
41
|
}
|
|
53
|
-
|
|
42
|
+
|
|
54
43
|
_connectWebSocket() {
|
|
55
44
|
return new Promise((resolve, reject) => {
|
|
56
|
-
if (this.signal?.aborted)
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
45
|
+
if (this.signal?.aborted) return reject(new Error('Aborted'));
|
|
46
|
+
|
|
60
47
|
const url = new URL(this.serverUrl);
|
|
61
48
|
url.searchParams.set('token', this.accessToken);
|
|
62
|
-
|
|
49
|
+
|
|
63
50
|
this.ws = new WebSocket(url.toString(), {
|
|
64
51
|
handshakeTimeout: 10000,
|
|
65
52
|
perMessageDeflate: false,
|
|
66
53
|
});
|
|
67
|
-
|
|
68
|
-
const onAbort = () => {
|
|
69
|
-
this.ws?.terminate();
|
|
70
|
-
reject(new Error('Aborted'));
|
|
71
|
-
};
|
|
72
|
-
|
|
54
|
+
|
|
55
|
+
const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
|
|
73
56
|
this.signal?.addEventListener('abort', onAbort);
|
|
74
|
-
|
|
57
|
+
|
|
75
58
|
this.ws.on('open', () => {
|
|
76
59
|
this.signal?.removeEventListener('abort', onAbort);
|
|
77
60
|
this.logger?.info('Tunnel WebSocket connected');
|
|
78
61
|
});
|
|
79
|
-
|
|
80
|
-
this.ws.on('message', (data) =>
|
|
81
|
-
|
|
82
|
-
});
|
|
83
|
-
|
|
62
|
+
|
|
63
|
+
this.ws.on('message', (data) => this._handleMessage(data));
|
|
64
|
+
|
|
84
65
|
this.ws.on('close', (code, reason) => {
|
|
85
66
|
this.connected = false;
|
|
86
67
|
this._stopHeartbeat();
|
|
87
|
-
|
|
68
|
+
this._cleanupLocalWs();
|
|
88
69
|
if (!this.signal?.aborted) {
|
|
89
70
|
this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
|
|
90
71
|
this._scheduleReconnect();
|
|
91
72
|
}
|
|
92
73
|
});
|
|
93
|
-
|
|
74
|
+
|
|
94
75
|
this.ws.on('error', (err) => {
|
|
95
76
|
this.logger?.error('Tunnel WebSocket error: %s', err.message);
|
|
96
|
-
|
|
97
77
|
if (!this.connected) {
|
|
98
78
|
this.signal?.removeEventListener('abort', onAbort);
|
|
99
79
|
reject(err);
|
|
100
80
|
}
|
|
101
81
|
});
|
|
102
|
-
|
|
103
|
-
// Listen for 'ready' message from server
|
|
82
|
+
|
|
104
83
|
const readyHandler = (data) => {
|
|
105
84
|
try {
|
|
106
85
|
const msg = JSON.parse(data.toString());
|
|
@@ -114,10 +93,8 @@ export class CustomTunnelClient {
|
|
|
114
93
|
}
|
|
115
94
|
} catch {}
|
|
116
95
|
};
|
|
117
|
-
|
|
118
96
|
this.ws.on('message', readyHandler);
|
|
119
|
-
|
|
120
|
-
// Timeout
|
|
97
|
+
|
|
121
98
|
setTimeout(() => {
|
|
122
99
|
if (!this.connected) {
|
|
123
100
|
this.signal?.removeEventListener('abort', onAbort);
|
|
@@ -127,126 +104,150 @@ export class CustomTunnelClient {
|
|
|
127
104
|
}, 15000);
|
|
128
105
|
});
|
|
129
106
|
}
|
|
130
|
-
|
|
107
|
+
|
|
131
108
|
_handleMessage(data) {
|
|
132
109
|
try {
|
|
133
110
|
const msg = JSON.parse(data.toString());
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (msg.type === '
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Response for our request
|
|
140
|
-
else if (msg.type === 'response') {
|
|
141
|
-
this._handleHttpResponse(msg);
|
|
142
|
-
}
|
|
143
|
-
// Heartbeat pong
|
|
144
|
-
else if (msg.type === 'pong') {
|
|
145
|
-
// Server is alive
|
|
146
|
-
}
|
|
111
|
+
if (msg.type === 'request') this._handleHttpRequest(msg);
|
|
112
|
+
else if (msg.type === 'ws-open') this._handleWsOpen(msg);
|
|
113
|
+
else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
|
|
114
|
+
else if (msg.type === 'ws-close') this._handleWsClose(msg);
|
|
115
|
+
// pong: ignore
|
|
147
116
|
} catch (err) {
|
|
148
117
|
this.logger?.error('Failed to parse tunnel message: %s', err.message);
|
|
149
118
|
}
|
|
150
119
|
}
|
|
151
|
-
|
|
120
|
+
|
|
121
|
+
// ── HTTP 请求代理 ─────────────────────────────────────────────────────────
|
|
152
122
|
_handleHttpRequest(msg) {
|
|
153
123
|
const { requestId, method, path, headers } = msg;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const SKIP_HEADERS = new Set([
|
|
157
|
-
'transfer-encoding', 'connection', 'keep-alive',
|
|
158
|
-
'proxy-authenticate', 'proxy-authorization',
|
|
159
|
-
'te', 'trailer', 'upgrade',
|
|
160
|
-
]);
|
|
124
|
+
const SKIP = new Set(['transfer-encoding','connection','keep-alive',
|
|
125
|
+
'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
|
|
161
126
|
const safeHeaders = Object.fromEntries(
|
|
162
|
-
Object.entries(headers ?? {}).filter(([k]) => !
|
|
127
|
+
Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
163
128
|
);
|
|
164
129
|
|
|
165
130
|
const req = httpRequest({
|
|
166
|
-
host: '127.0.0.1',
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
path: path || '/',
|
|
170
|
-
headers: {
|
|
171
|
-
...safeHeaders,
|
|
172
|
-
host: `127.0.0.1:${this.localPort}`,
|
|
173
|
-
},
|
|
131
|
+
host: '127.0.0.1', port: this.localPort,
|
|
132
|
+
method, path: path || '/',
|
|
133
|
+
headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
|
|
174
134
|
}, (res) => {
|
|
175
135
|
const chunks = [];
|
|
176
|
-
res.on('data',
|
|
177
|
-
res.on('error', (
|
|
178
|
-
this.
|
|
179
|
-
this._sendMessage({
|
|
180
|
-
type: 'response', requestId,
|
|
181
|
-
statusCode: 502,
|
|
136
|
+
res.on('data', c => chunks.push(c));
|
|
137
|
+
res.on('error', () => {
|
|
138
|
+
this._sendMessage({ type: 'response', requestId, statusCode: 502,
|
|
182
139
|
headers: { 'content-type': 'text/plain' },
|
|
183
|
-
body: Buffer.from('Response Error').toString('base64')
|
|
184
|
-
});
|
|
140
|
+
body: Buffer.from('Response Error').toString('base64') });
|
|
185
141
|
});
|
|
186
142
|
res.on('end', () => {
|
|
187
|
-
// 过滤响应头里的 hop-by-hop,防止外层 HTTP 解析出错
|
|
188
143
|
const respHeaders = Object.fromEntries(
|
|
189
|
-
Object.entries(res.headers).filter(([k]) => !
|
|
144
|
+
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
190
145
|
);
|
|
191
|
-
this._sendMessage({
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
statusCode: res.statusCode,
|
|
195
|
-
headers: respHeaders,
|
|
196
|
-
body: Buffer.concat(chunks).toString('base64'),
|
|
197
|
-
});
|
|
146
|
+
this._sendMessage({ type: 'response', requestId,
|
|
147
|
+
statusCode: res.statusCode, headers: respHeaders,
|
|
148
|
+
body: Buffer.concat(chunks).toString('base64') });
|
|
198
149
|
});
|
|
199
150
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
this.logger?.error('Local request failed: %s', err.message);
|
|
203
|
-
this._sendMessage({
|
|
204
|
-
type: 'response', requestId,
|
|
205
|
-
statusCode: 502,
|
|
151
|
+
req.on('error', err => {
|
|
152
|
+
this._sendMessage({ type: 'response', requestId, statusCode: 502,
|
|
206
153
|
headers: { 'content-type': 'text/plain' },
|
|
207
|
-
body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64')
|
|
208
|
-
});
|
|
154
|
+
body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
|
|
209
155
|
});
|
|
210
|
-
|
|
211
|
-
if (msg.body) {
|
|
212
|
-
req.write(Buffer.from(msg.body, 'base64'));
|
|
213
|
-
}
|
|
156
|
+
if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
|
|
214
157
|
req.end();
|
|
215
158
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
159
|
+
|
|
160
|
+
// ── WebSocket 升级代理 ────────────────────────────────────────────────────
|
|
161
|
+
// 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
|
|
162
|
+
_handleWsOpen(msg) {
|
|
163
|
+
const { wsId, path, headers } = msg;
|
|
164
|
+
|
|
165
|
+
const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
|
|
166
|
+
this.localWsSockets.set(wsId, sock);
|
|
167
|
+
|
|
168
|
+
// 构造 HTTP Upgrade 请求
|
|
169
|
+
const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
|
|
170
|
+
delete reqHeaders['proxy-connection'];
|
|
171
|
+
delete reqHeaders['proxy-authorization'];
|
|
172
|
+
|
|
173
|
+
const lines = [`GET ${path || '/'} HTTP/1.1`];
|
|
174
|
+
for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
|
|
175
|
+
lines.push('', '');
|
|
176
|
+
sock.write(lines.join('\r\n'));
|
|
177
|
+
|
|
178
|
+
let headerBuf = '';
|
|
179
|
+
let upgraded = false;
|
|
180
|
+
|
|
181
|
+
sock.on('data', (chunk) => {
|
|
182
|
+
if (upgraded) {
|
|
183
|
+
this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
headerBuf += chunk.toString('binary');
|
|
187
|
+
const sep = headerBuf.indexOf('\r\n\r\n');
|
|
188
|
+
if (sep === -1) return;
|
|
189
|
+
|
|
190
|
+
upgraded = true;
|
|
191
|
+
const replyHeaders = {};
|
|
192
|
+
const headerLines = headerBuf.slice(0, sep).split('\r\n');
|
|
193
|
+
for (let i = 1; i < headerLines.length; i++) {
|
|
194
|
+
const ci = headerLines[i].indexOf(':');
|
|
195
|
+
if (ci > 0) {
|
|
196
|
+
replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
|
|
197
|
+
headerLines[i].slice(ci + 1).trim();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
|
|
201
|
+
|
|
202
|
+
// 握手后紧跟的帧数据
|
|
203
|
+
const rest = headerBuf.slice(sep + 4);
|
|
204
|
+
if (rest.length > 0) {
|
|
205
|
+
this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
sock.on('close', () => {
|
|
210
|
+
this._sendMessage({ type: 'ws-close', wsId });
|
|
211
|
+
this.localWsSockets.delete(wsId);
|
|
212
|
+
});
|
|
213
|
+
sock.on('error', (err) => {
|
|
214
|
+
this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
|
|
215
|
+
this._sendMessage({ type: 'ws-close', wsId });
|
|
216
|
+
this.localWsSockets.delete(wsId);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_handleWsFrame(msg) {
|
|
221
|
+
const sock = this.localWsSockets.get(msg.wsId);
|
|
222
|
+
if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_handleWsClose(msg) {
|
|
226
|
+
const sock = this.localWsSockets.get(msg.wsId);
|
|
227
|
+
if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_cleanupLocalWs() {
|
|
231
|
+
for (const [, sock] of this.localWsSockets) sock.destroy();
|
|
232
|
+
this.localWsSockets.clear();
|
|
225
233
|
}
|
|
226
|
-
|
|
234
|
+
|
|
235
|
+
// ── 工具方法 ──────────────────────────────────────────────────────────────
|
|
227
236
|
_sendMessage(msg) {
|
|
228
|
-
if (this.ws?.readyState === WebSocket.OPEN)
|
|
229
|
-
this.ws.send(JSON.stringify(msg));
|
|
230
|
-
}
|
|
237
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
|
231
238
|
}
|
|
232
|
-
|
|
239
|
+
|
|
233
240
|
_startHeartbeat() {
|
|
234
241
|
this._stopHeartbeat();
|
|
235
|
-
|
|
236
242
|
this.heartbeatTimer = setInterval(() => {
|
|
237
|
-
if (this.connected) {
|
|
238
|
-
this._sendMessage({ type: 'ping' });
|
|
239
|
-
}
|
|
243
|
+
if (this.connected) this._sendMessage({ type: 'ping' });
|
|
240
244
|
}, HEARTBEAT_INTERVAL);
|
|
241
245
|
}
|
|
242
|
-
|
|
246
|
+
|
|
243
247
|
_stopHeartbeat() {
|
|
244
|
-
if (this.heartbeatTimer) {
|
|
245
|
-
clearInterval(this.heartbeatTimer);
|
|
246
|
-
this.heartbeatTimer = null;
|
|
247
|
-
}
|
|
248
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
248
249
|
}
|
|
249
|
-
|
|
250
|
+
|
|
250
251
|
_scheduleReconnect() {
|
|
251
252
|
if (this.signal?.aborted || this.disconnecting) return;
|
|
252
253
|
if (this.reconnectTimer) return;
|
|
@@ -254,50 +255,29 @@ export class CustomTunnelClient {
|
|
|
254
255
|
this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
|
|
255
256
|
return;
|
|
256
257
|
}
|
|
257
|
-
|
|
258
258
|
this.reconnectAttempts++;
|
|
259
259
|
const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
|
|
260
|
-
|
|
261
260
|
this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
262
|
-
|
|
263
261
|
this.reconnectTimer = setTimeout(() => {
|
|
264
262
|
this.reconnectTimer = null;
|
|
265
|
-
this.connect().catch(() => {
|
|
266
|
-
// Will schedule another reconnect
|
|
267
|
-
});
|
|
263
|
+
this.connect().catch(() => {});
|
|
268
264
|
}, delay);
|
|
269
265
|
}
|
|
270
|
-
|
|
266
|
+
|
|
271
267
|
_setState(phase, detail) {
|
|
272
|
-
if (this.onStateChange) {
|
|
273
|
-
this.onStateChange({ phase, detail });
|
|
274
|
-
}
|
|
268
|
+
if (this.onStateChange) this.onStateChange({ phase, detail });
|
|
275
269
|
}
|
|
276
|
-
|
|
270
|
+
|
|
277
271
|
disconnect() {
|
|
278
|
-
// Mark as disconnected (don't modify external signal)
|
|
279
272
|
this.disconnecting = true;
|
|
280
273
|
this._stopHeartbeat();
|
|
281
|
-
|
|
282
|
-
if (this.reconnectTimer) {
|
|
283
|
-
|
|
284
|
-
this.reconnectTimer = null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (this.ws) {
|
|
288
|
-
this.ws.close();
|
|
289
|
-
this.ws = null;
|
|
290
|
-
}
|
|
291
|
-
|
|
274
|
+
this._cleanupLocalWs();
|
|
275
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
276
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
292
277
|
this.connected = false;
|
|
293
278
|
this.publicUrl = null;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
297
|
-
pending.reject(new Error('Disconnected'));
|
|
298
|
-
this.pendingRequests.delete(id);
|
|
299
|
-
}
|
|
300
|
-
|
|
279
|
+
for (const [, p] of this.pendingRequests) p.reject(new Error('Disconnected'));
|
|
280
|
+
this.pendingRequests.clear();
|
|
301
281
|
this.logger?.info('Tunnel disconnected');
|
|
302
282
|
}
|
|
303
283
|
}
|