@wenbin_wb/dsh-bridge 1.0.5 → 1.0.7
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/README.md +89 -43
- package/lib/tunnel-client.mjs +136 -156
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -2,132 +2,178 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
+
> Multi-channel remote access plugin for DeepSeek Harness
|
|
5
6
|
> DeepSeek Harness 多通道远程访问插件
|
|
6
7
|
|
|
8
|
+
Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public networks, and even IM chat apps. Access your AI assistant anytime, anywhere via QR code scanning, web browsers, or future integrations with WeChat/QQ/Feishu.
|
|
9
|
+
|
|
7
10
|
把你本地的 DeepSeek Harness 无缝延伸到手机、平板、公网、甚至 IM 聊天软件。无论你在哪,都能通过扫码、网页或未来的微信/QQ/飞书,随时调用你的 AI 助手。
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Features / 功能特性
|
|
15
|
+
|
|
16
|
+
### English
|
|
17
|
+
|
|
18
|
+
- **LAN Access**: Scan QR code with your smartphone/tablet, direct access on the same Wi-Fi
|
|
19
|
+
- **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere
|
|
20
|
+
- **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
|
|
21
|
+
- **IM Integration (Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
|
|
22
|
+
- **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
|
|
23
|
+
- **Auto Version Check**: Automatic update detection when entering the panel
|
|
24
|
+
|
|
25
|
+
### 中文
|
|
10
26
|
|
|
11
|
-
-
|
|
27
|
+
- **局域网访问**:手机/平板扫码,同一 Wi-Fi 直接访问
|
|
12
28
|
- **Cloudflare 隧道**:一键暴露公网地址,随时随地连接
|
|
13
|
-
-
|
|
29
|
+
- **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
|
|
14
30
|
- **IM 集成(规划中)**:微信 / QQ / 飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
|
|
31
|
+
- **安全提示**:URL 和二维码带访问警告,防止误分享
|
|
32
|
+
- **版本检查**:进入面板自动检测是否有新版本
|
|
15
33
|
|
|
16
34
|

|
|
17
35
|

|
|
18
36
|

|
|
19
37
|

|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- **局域网访问**:自动检测本机 IP,生成二维码,同一 Wi-Fi 下的设备直接扫码访问
|
|
24
|
-
- **Cloudflare 隧道**:一键获取公网地址,自动下载 cloudflared(无需手动安装),重启后 URL 会变化
|
|
25
|
-
- **自建隧道**:WebSocket 反向隧道,连接自己部署的隧道服务器,可获得固定域名
|
|
26
|
-
- **安全提示**:URL 和二维码带访问警告,防止误分享
|
|
27
|
-
- **版本检查**:进入面板自动检测是否有新版本
|
|
39
|
+
---
|
|
28
40
|
|
|
29
41
|
## Roadmap
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
| Target / 目标 | Description / 说明 | Status / 状态 |
|
|
44
|
+
|---------------|-------------------|--------------|
|
|
45
|
+
| **WeChat / 微信** | Chat with your Agent directly in WeChat / 在微信里直接与你的 Agent 对话 | Planned / 规划中 |
|
|
46
|
+
| **QQ Bot** | QQ bot integration for group/private chat / 接入 QQ 机器人,群聊/私聊唤起 Agent | Planned / 规划中 |
|
|
47
|
+
| **Feishu / 飞书** | Feishu message/bot integration for workplace scenarios / 飞书消息/机器人集成,办公场景直接调用 | Planned / 规划中 |
|
|
48
|
+
| **OpenClaw** | Integration with OpenClaw ecosystem / 与 OpenClaw 生态打通 | Planned / 规划中 |
|
|
49
|
+
| **Telegram** | Self-hosted IM channel / 适合自托管的 IM 渠道 | Planned / 规划中 |
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|------|------|------|
|
|
35
|
-
| **微信** | 在微信里直接与你的 Agent 对话 | 规划中 |
|
|
36
|
-
| **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | 规划中 |
|
|
37
|
-
| **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
|
|
38
|
-
| **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
|
|
39
|
-
| **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
|
|
51
|
+
---
|
|
40
52
|
|
|
41
|
-
## 环境要求
|
|
53
|
+
## Requirements / 环境要求
|
|
42
54
|
|
|
55
|
+
Before installing the plugin, ensure:
|
|
43
56
|
安装插件前,请先确保:
|
|
44
57
|
|
|
45
|
-
1. **Node.js ≥ 22
|
|
46
|
-
2. **dsh CLI
|
|
58
|
+
1. **Node.js ≥ 22** (DSH requires `^22.19.0` or `≥ 24.0.0` / DSH 要求)
|
|
59
|
+
2. **dsh CLI available** — you can run `dsh` command in terminal / 能在终端直接运行 `dsh` 命令
|
|
47
60
|
|
|
48
61
|
```bash
|
|
49
|
-
# 检查 Node 版本
|
|
50
|
-
node -v #
|
|
62
|
+
# Check Node version / 检查 Node 版本
|
|
63
|
+
node -v # Should show v22.19+ or v24+ / 应显示
|
|
51
64
|
|
|
52
|
-
# 检查 dsh 是否可用
|
|
65
|
+
# Check dsh availability / 检查 dsh 是否可用
|
|
53
66
|
dsh --version
|
|
54
67
|
```
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
If `dsh` command is not found, install DSH globally:
|
|
70
|
+
如果 `dsh` 命令提示"无法识别/找不到",先安装 DSH:
|
|
57
71
|
|
|
58
72
|
```bash
|
|
59
73
|
npm install -g @deepseek-ai/dsh
|
|
60
74
|
```
|
|
61
75
|
|
|
62
|
-
>
|
|
76
|
+
> Alternative without global install (use `npx`):
|
|
77
|
+
> 若没有全局安装的权限,也可以用 `npx` 方式:
|
|
63
78
|
> ```bash
|
|
64
79
|
> npx --yes @deepseek-ai/dsh plugin --profile web add @wenbin_wb/dsh-bridge
|
|
65
80
|
> ```
|
|
66
81
|
|
|
67
|
-
|
|
82
|
+
---
|
|
68
83
|
|
|
69
|
-
|
|
84
|
+
## Installation / 安装
|
|
85
|
+
|
|
86
|
+
### From npm / 从 npm 安装
|
|
70
87
|
|
|
71
88
|
```bash
|
|
72
89
|
dsh plugin --profile web add @wenbin_wb/dsh-bridge
|
|
73
90
|
```
|
|
74
91
|
|
|
75
|
-
|
|
92
|
+
### From source / 从源码安装
|
|
76
93
|
|
|
77
94
|
```bash
|
|
78
95
|
git clone https://github.com/wenbin-wb/dsh-bridge.git
|
|
79
96
|
dsh plugin --profile web add ./dsh-bridge
|
|
80
97
|
```
|
|
81
98
|
|
|
99
|
+
After installation, restart DSH and find "Remote Access" in settings.
|
|
82
100
|
安装完成后重启 DSH,在设置页找到「远程访问」即可使用。
|
|
83
101
|
|
|
84
|
-
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Usage / 使用
|
|
105
|
+
|
|
106
|
+
### LAN Access / 局域网访问
|
|
107
|
+
|
|
108
|
+
**English**: Automatically enabled after plugin starts, no configuration needed. Open Settings → "Remote Access", scan the QR code with your mobile device.
|
|
109
|
+
|
|
110
|
+
**中文**:插件启动后自动开启,无需任何配置。打开设置页「远程访问」,用手机扫描二维码即可访问。
|
|
85
111
|
|
|
86
|
-
###
|
|
112
|
+
### Cloudflare Tunnel
|
|
87
113
|
|
|
88
|
-
|
|
114
|
+
**English**:
|
|
115
|
+
1. Click "Enable" in the Cloudflare Tunnel card
|
|
116
|
+
2. First use will auto-download cloudflared (~30MB) from GitHub
|
|
117
|
+
3. Public URL and QR code appear within seconds
|
|
118
|
+
4. URL changes after each restart; click "Reset Link" to get a new one
|
|
89
119
|
|
|
90
|
-
|
|
120
|
+
**中文**:
|
|
121
|
+
1. 点击「Cloudflare 隧道」卡片中的「开启」按钮
|
|
122
|
+
2. 首次使用会自动从 GitHub 下载 cloudflared(约 30MB)
|
|
123
|
+
3. 下载完成后自动启动,几秒内显示公网 URL 和二维码
|
|
124
|
+
4. 每次重启后 URL 会变化;点「重置链接」可主动获取新 URL
|
|
91
125
|
|
|
92
|
-
|
|
93
|
-
1. 首次使用会自动从 GitHub 下载 cloudflared(约 30MB),需要能访问 GitHub
|
|
94
|
-
2. 下载完成后自动启动,几秒内显示公网 URL 和二维码
|
|
95
|
-
3. 每次重启后 URL 会变化;点「重置链接」可主动获取新 URL
|
|
126
|
+
### Custom Tunnel / 自建隧道
|
|
96
127
|
|
|
97
|
-
|
|
128
|
+
**English**: Requires a server with public IP. See [Custom Tunnel Guide](docs/custom-tunnel.md) for detailed setup.
|
|
98
129
|
|
|
99
|
-
|
|
130
|
+
1. Deploy tunnel server following the guide
|
|
131
|
+
2. Fill in WebSocket address (`wss://...`) and access token in the "Custom Tunnel" card
|
|
132
|
+
3. Click "Save Config" then "Enable"
|
|
133
|
+
|
|
134
|
+
Config is auto-persisted to `~/.dsh/dsh-bridge/config.json`, no need to re-enter after restart.
|
|
135
|
+
|
|
136
|
+
**中文**:需要一台有公网 IP 的服务器。详细搭建步骤见 [自建隧道教程](docs/custom-tunnel.md)。
|
|
100
137
|
|
|
101
138
|
1. 按教程在服务器上部署隧道服务端
|
|
102
139
|
2. 在「自建隧道」卡片中填写 WebSocket 地址(`wss://...`)和访问令牌
|
|
103
140
|
3. 点「保存配置」后点「开启」
|
|
104
141
|
|
|
105
|
-
|
|
142
|
+
配置自动持久化,重启后无需重新填写。
|
|
143
|
+
|
|
144
|
+
---
|
|
106
145
|
|
|
107
|
-
## 可选配置
|
|
146
|
+
## Optional Configuration / 可选配置
|
|
108
147
|
|
|
148
|
+
Plugin works out-of-the-box, no configuration needed. To change proxy port, add to cordis.yml:
|
|
109
149
|
插件开箱即用,无需配置。如需修改代理端口,在 cordis.yml 中添加:
|
|
110
150
|
|
|
111
151
|
```yaml
|
|
112
152
|
- name: dsh-bridge
|
|
113
153
|
config:
|
|
114
|
-
port: 3082 # 默认 3082
|
|
154
|
+
port: 3082 # Default / 默认 3082
|
|
115
155
|
```
|
|
116
156
|
|
|
117
|
-
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Development / 开发
|
|
118
160
|
|
|
119
161
|
```bash
|
|
120
162
|
git clone https://github.com/wenbin-wb/dsh-bridge.git
|
|
121
163
|
cd dsh-bridge
|
|
122
164
|
npm install
|
|
123
165
|
|
|
166
|
+
# Rebuild after modifying client/index.js
|
|
124
167
|
# 修改 client/index.js 后重新构建
|
|
125
168
|
npm run build:client
|
|
126
169
|
|
|
170
|
+
# Install to web profile and restart DSH
|
|
127
171
|
# 安装到 web profile 并重启 DSH
|
|
128
172
|
dsh plugin --profile web add .
|
|
129
173
|
```
|
|
130
174
|
|
|
175
|
+
---
|
|
176
|
+
|
|
131
177
|
## License
|
|
132
178
|
|
|
133
179
|
MIT © [wenbin-wb](https://github.com/wenbin-wb)
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "DSH 多渠道接入桥:局域网二维码、自建隧道、机器人集成",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -40,9 +40,19 @@
|
|
|
40
40
|
"dsh",
|
|
41
41
|
"dsh-plugin",
|
|
42
42
|
"remote",
|
|
43
|
+
"remote-access",
|
|
43
44
|
"tunnel",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
45
|
+
"cloudflare",
|
|
46
|
+
"mobile",
|
|
47
|
+
"smartphone",
|
|
48
|
+
"tablet",
|
|
49
|
+
"qr-code",
|
|
50
|
+
"qrcode",
|
|
51
|
+
"websocket",
|
|
52
|
+
"bot",
|
|
53
|
+
"wechat",
|
|
54
|
+
"qq",
|
|
55
|
+
"feishu"
|
|
46
56
|
],
|
|
47
57
|
"dsh": {
|
|
48
58
|
"bundle": {
|