@wenbin_wb/dsh-bridge 1.0.4 → 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 +142 -150
- 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,114 +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
|
-
|
|
124
|
+
const SKIP = new Set(['transfer-encoding','connection','keep-alive',
|
|
125
|
+
'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
|
|
126
|
+
const safeHeaders = Object.fromEntries(
|
|
127
|
+
Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
128
|
+
);
|
|
129
|
+
|
|
156
130
|
const req = httpRequest({
|
|
157
|
-
host: '127.0.0.1',
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
path,
|
|
161
|
-
headers: {
|
|
162
|
-
...headers,
|
|
163
|
-
host: `127.0.0.1:${this.localPort}`,
|
|
164
|
-
},
|
|
131
|
+
host: '127.0.0.1', port: this.localPort,
|
|
132
|
+
method, path: path || '/',
|
|
133
|
+
headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
|
|
165
134
|
}, (res) => {
|
|
166
135
|
const chunks = [];
|
|
167
|
-
|
|
168
|
-
res.on('
|
|
169
|
-
|
|
136
|
+
res.on('data', c => chunks.push(c));
|
|
137
|
+
res.on('error', () => {
|
|
138
|
+
this._sendMessage({ type: 'response', requestId, statusCode: 502,
|
|
139
|
+
headers: { 'content-type': 'text/plain' },
|
|
140
|
+
body: Buffer.from('Response Error').toString('base64') });
|
|
170
141
|
});
|
|
171
|
-
|
|
172
142
|
res.on('end', () => {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
headers: res.headers,
|
|
180
|
-
body,
|
|
181
|
-
});
|
|
143
|
+
const respHeaders = Object.fromEntries(
|
|
144
|
+
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
145
|
+
);
|
|
146
|
+
this._sendMessage({ type: 'response', requestId,
|
|
147
|
+
statusCode: res.statusCode, headers: respHeaders,
|
|
148
|
+
body: Buffer.concat(chunks).toString('base64') });
|
|
182
149
|
});
|
|
183
150
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.logger?.error('Local request failed: %s', err.message);
|
|
187
|
-
|
|
188
|
-
this._sendMessage({
|
|
189
|
-
type: 'response',
|
|
190
|
-
requestId,
|
|
191
|
-
statusCode: 502,
|
|
151
|
+
req.on('error', err => {
|
|
152
|
+
this._sendMessage({ type: 'response', requestId, statusCode: 502,
|
|
192
153
|
headers: { 'content-type': 'text/plain' },
|
|
193
|
-
body: Buffer.from(
|
|
194
|
-
});
|
|
154
|
+
body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
|
|
195
155
|
});
|
|
196
|
-
|
|
197
|
-
// Send request body if present
|
|
198
|
-
if (msg.body) {
|
|
199
|
-
req.write(Buffer.from(msg.body, 'base64'));
|
|
200
|
-
}
|
|
201
|
-
|
|
156
|
+
if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
|
|
202
157
|
req.end();
|
|
203
158
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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();
|
|
213
233
|
}
|
|
214
|
-
|
|
234
|
+
|
|
235
|
+
// ── 工具方法 ──────────────────────────────────────────────────────────────
|
|
215
236
|
_sendMessage(msg) {
|
|
216
|
-
if (this.ws?.readyState === WebSocket.OPEN)
|
|
217
|
-
this.ws.send(JSON.stringify(msg));
|
|
218
|
-
}
|
|
237
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
|
219
238
|
}
|
|
220
|
-
|
|
239
|
+
|
|
221
240
|
_startHeartbeat() {
|
|
222
241
|
this._stopHeartbeat();
|
|
223
|
-
|
|
224
242
|
this.heartbeatTimer = setInterval(() => {
|
|
225
|
-
if (this.connected) {
|
|
226
|
-
this._sendMessage({ type: 'ping' });
|
|
227
|
-
}
|
|
243
|
+
if (this.connected) this._sendMessage({ type: 'ping' });
|
|
228
244
|
}, HEARTBEAT_INTERVAL);
|
|
229
245
|
}
|
|
230
|
-
|
|
246
|
+
|
|
231
247
|
_stopHeartbeat() {
|
|
232
|
-
if (this.heartbeatTimer) {
|
|
233
|
-
clearInterval(this.heartbeatTimer);
|
|
234
|
-
this.heartbeatTimer = null;
|
|
235
|
-
}
|
|
248
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
236
249
|
}
|
|
237
|
-
|
|
250
|
+
|
|
238
251
|
_scheduleReconnect() {
|
|
239
252
|
if (this.signal?.aborted || this.disconnecting) return;
|
|
240
253
|
if (this.reconnectTimer) return;
|
|
@@ -242,50 +255,29 @@ export class CustomTunnelClient {
|
|
|
242
255
|
this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
|
|
243
256
|
return;
|
|
244
257
|
}
|
|
245
|
-
|
|
246
258
|
this.reconnectAttempts++;
|
|
247
259
|
const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
|
|
248
|
-
|
|
249
260
|
this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
250
|
-
|
|
251
261
|
this.reconnectTimer = setTimeout(() => {
|
|
252
262
|
this.reconnectTimer = null;
|
|
253
|
-
this.connect().catch(() => {
|
|
254
|
-
// Will schedule another reconnect
|
|
255
|
-
});
|
|
263
|
+
this.connect().catch(() => {});
|
|
256
264
|
}, delay);
|
|
257
265
|
}
|
|
258
|
-
|
|
266
|
+
|
|
259
267
|
_setState(phase, detail) {
|
|
260
|
-
if (this.onStateChange) {
|
|
261
|
-
this.onStateChange({ phase, detail });
|
|
262
|
-
}
|
|
268
|
+
if (this.onStateChange) this.onStateChange({ phase, detail });
|
|
263
269
|
}
|
|
264
|
-
|
|
270
|
+
|
|
265
271
|
disconnect() {
|
|
266
|
-
// Mark as disconnected (don't modify external signal)
|
|
267
272
|
this.disconnecting = true;
|
|
268
273
|
this._stopHeartbeat();
|
|
269
|
-
|
|
270
|
-
if (this.reconnectTimer) {
|
|
271
|
-
|
|
272
|
-
this.reconnectTimer = null;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (this.ws) {
|
|
276
|
-
this.ws.close();
|
|
277
|
-
this.ws = null;
|
|
278
|
-
}
|
|
279
|
-
|
|
274
|
+
this._cleanupLocalWs();
|
|
275
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
276
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
280
277
|
this.connected = false;
|
|
281
278
|
this.publicUrl = null;
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
285
|
-
pending.reject(new Error('Disconnected'));
|
|
286
|
-
this.pendingRequests.delete(id);
|
|
287
|
-
}
|
|
288
|
-
|
|
279
|
+
for (const [, p] of this.pendingRequests) p.reject(new Error('Disconnected'));
|
|
280
|
+
this.pendingRequests.clear();
|
|
289
281
|
this.logger?.info('Tunnel disconnected');
|
|
290
282
|
}
|
|
291
283
|
}
|