dsh-bridge-gateway 0.1.0
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 +250 -0
- package/LICENSE +21 -0
- package/README.en.md +408 -0
- package/README.md +430 -0
- package/client/build.mjs +43 -0
- package/client/client.js +5209 -0
- package/client/index.js +4967 -0
- package/cordis.patch.yml +4 -0
- package/docs/banner.jpg +0 -0
- package/docs/custom-tunnel.md +220 -0
- package/docs/feishu-usage.md +116 -0
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/qq-usage.md +387 -0
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -0
- package/docs/screenshots/mobile-chat.jpg +0 -0
- package/docs/screenshots/mobile-drawer.jpg +0 -0
- package/docs/screenshots/mobile-remote-settings.jpg +0 -0
- package/docs/screenshots/mobile-settings-im.jpg +0 -0
- package/docs/screenshots/mobile-settings-lan.jpg +0 -0
- package/docs/screenshots/mobile-settings-security.jpg +0 -0
- package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
- package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
- package/docs/screenshots/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +93 -0
- package/docs/wechat-usage.md +104 -0
- package/lib/auth/login-template.js +382 -0
- package/lib/auth/manager.js +470 -0
- package/lib/bridge-rpc-constants.js +55 -0
- package/lib/bridge-rpc.js +553 -0
- package/lib/cloudflared-manager.mjs +345 -0
- package/lib/feishu/gateway.js +550 -0
- package/lib/feishu/index.js +222 -0
- package/lib/feishu/lark-bundled.mjs +125547 -0
- package/lib/feishu/node.js +409 -0
- package/lib/gateway/cert.js +214 -0
- package/lib/index.js +1963 -0
- package/lib/platform/base.js +156 -0
- package/lib/platform/conversation-bridge.js +1570 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/manager.js +74 -0
- package/lib/qq/gateway.js +616 -0
- package/lib/qq/index.js +309 -0
- package/lib/qq/node.js +533 -0
- package/lib/security/path-validator.js +208 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/telegram/gateway.js +630 -0
- package/lib/telegram/index.js +213 -0
- package/lib/telegram/node.js +351 -0
- package/lib/tunnel-client.mjs +402 -0
- package/lib/wechat/gateway.js +960 -0
- package/lib/wechat/index.js +241 -0
- package/lib/wechat/media.js +281 -0
- package/lib/wechat/node.js +350 -0
- package/package.json +102 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1963 @@
|
|
|
1
|
+
// dsh-bridge 主插件(Host)
|
|
2
|
+
//
|
|
3
|
+
// 多渠道访问桥:
|
|
4
|
+
// 1. 局域网访问代理(自动启动,零配置)
|
|
5
|
+
// 2. Cloudflare 隧道(一键获取公网地址)
|
|
6
|
+
// 3. 自建隧道(WebSocket 反向隧道 + Token 认证)
|
|
7
|
+
|
|
8
|
+
import { createServer, request as httpRequest, get as httpGet } from 'node:http';
|
|
9
|
+
import { get as httpsGet, createServer as createHttpsServer } from 'node:https';
|
|
10
|
+
import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
|
|
11
|
+
import { join, dirname, basename, resolve, normalize } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
17
|
+
import QRCode from 'qrcode';
|
|
18
|
+
import { installBridgeRpc } from './bridge-rpc.js';
|
|
19
|
+
import { CustomTunnelClient } from './tunnel-client.mjs';
|
|
20
|
+
import { CloudflaredManager } from './cloudflared-manager.mjs';
|
|
21
|
+
import { PlatformManager } from './platform/manager.js';
|
|
22
|
+
import { WechatService } from './wechat/index.js';
|
|
23
|
+
import { QqService } from './qq/index.js';
|
|
24
|
+
import { FeishuService } from './feishu/index.js';
|
|
25
|
+
import { TelegramService } from './telegram/index.js';
|
|
26
|
+
import { AuthManager } from './auth/manager.js';
|
|
27
|
+
import { renderLoginPage } from './auth/login-template.js';
|
|
28
|
+
import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
|
|
29
|
+
import { getOrCreateSelfSignedCert } from './gateway/cert.js';
|
|
30
|
+
|
|
31
|
+
const name = 'dsh-bridge';
|
|
32
|
+
// 公网直连网关默认监听端口(自带 HTTPS 自签证书)
|
|
33
|
+
const DEFAULT_GATEWAY_PORT = 7443;
|
|
34
|
+
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
35
|
+
const inject = ['connection', 'webServer', 'sessions', 'agents', 'approval', 'workspaceRegistry', 'sessionPersistence'];
|
|
36
|
+
|
|
37
|
+
// 从 package.json 动态读取版本号,发版只需改 package.json 一处
|
|
38
|
+
const PACKAGE_JSON = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
39
|
+
const VERSION = PACKAGE_JSON.version ?? '0.0.0';
|
|
40
|
+
|
|
41
|
+
const VIRTUAL_KEYWORDS = [
|
|
42
|
+
'vethernet', 'wsl', 'hyper-v', 'virtual', 'vmware', 'vbox', 'docker',
|
|
43
|
+
'tailscale', 'zerotier', 'tap', 'tun', 'utun', 'wireguard', 'loopback', 'bridge',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 列出所有可用的局域网 IPv4 网卡与 IP 地址(按推荐优先级排序)
|
|
48
|
+
*/
|
|
49
|
+
function listAllLanIPv4() {
|
|
50
|
+
const interfaces = networkInterfaces();
|
|
51
|
+
const list = [];
|
|
52
|
+
|
|
53
|
+
for (const [ifname, addrs] of Object.entries(interfaces)) {
|
|
54
|
+
if (!addrs) continue;
|
|
55
|
+
const lower = ifname.toLowerCase();
|
|
56
|
+
const isVirtual = VIRTUAL_KEYWORDS.some((kw) => lower.includes(kw));
|
|
57
|
+
|
|
58
|
+
for (const addr of addrs) {
|
|
59
|
+
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
60
|
+
|
|
61
|
+
let score = 0;
|
|
62
|
+
// 1. IP 网段优先(家庭/企业物理局域网最常用网段)
|
|
63
|
+
if (addr.address.startsWith('192.168.')) score += 100;
|
|
64
|
+
else if (addr.address.startsWith('10.')) score += 90;
|
|
65
|
+
else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 70;
|
|
66
|
+
else score += 10;
|
|
67
|
+
|
|
68
|
+
// 2. 物理网卡与名称特征优先
|
|
69
|
+
if (isVirtual) {
|
|
70
|
+
score -= 200; // 虚拟网卡大幅降权
|
|
71
|
+
} else {
|
|
72
|
+
score += 100;
|
|
73
|
+
if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) score += 50;
|
|
74
|
+
else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) score += 40;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let label = ifname;
|
|
78
|
+
if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) label += ' (Wi-Fi 无线网卡)';
|
|
79
|
+
else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) label += ' (有线网卡)';
|
|
80
|
+
else if (isVirtual) label += ' (虚拟网卡 / WSL / 虚拟机)';
|
|
81
|
+
|
|
82
|
+
list.push({
|
|
83
|
+
name: ifname,
|
|
84
|
+
label,
|
|
85
|
+
address: addr.address,
|
|
86
|
+
netmask: addr.netmask,
|
|
87
|
+
isVirtual,
|
|
88
|
+
score,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return list.sort((a, b) => b.score - a.score);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 选择最佳默认局域网 IP
|
|
98
|
+
*/
|
|
99
|
+
function selectLanIPv4() {
|
|
100
|
+
const list = listAllLanIPv4();
|
|
101
|
+
return list[0]?.address || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 二维码缓存(带 TTL + LRU)
|
|
106
|
+
*/
|
|
107
|
+
class QrCache {
|
|
108
|
+
constructor(ttl = 30 * 60 * 1000, maxSize = 8) {
|
|
109
|
+
this.cache = new Map();
|
|
110
|
+
this.ttl = ttl;
|
|
111
|
+
this.maxSize = maxSize;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async get(text) {
|
|
115
|
+
const cached = this.cache.get(text);
|
|
116
|
+
if (cached && Date.now() - cached.time < this.ttl) {
|
|
117
|
+
return cached.data;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const qr = await QRCode.toDataURL(text, {
|
|
121
|
+
width: 300,
|
|
122
|
+
margin: 2,
|
|
123
|
+
color: { dark: '#1F2421', light: '#FFFFFF' },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.cache.set(text, { data: qr, time: Date.now() });
|
|
127
|
+
|
|
128
|
+
if (this.cache.size > this.maxSize) {
|
|
129
|
+
const oldest = Array.from(this.cache.entries())
|
|
130
|
+
.sort((a, b) => a[1].time - b[1].time)[0];
|
|
131
|
+
if (oldest) this.cache.delete(oldest[0]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return qr;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
clear() {
|
|
138
|
+
this.cache.clear();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* PWA Web App Manifest 与 App 启动图标
|
|
144
|
+
*/
|
|
145
|
+
const PWA_MANIFEST = JSON.stringify({
|
|
146
|
+
name: 'DeepSeek Harness',
|
|
147
|
+
short_name: 'DSH',
|
|
148
|
+
description: 'DeepSeek Harness Remote & Mobile Workspace',
|
|
149
|
+
start_url: '/',
|
|
150
|
+
display: 'standalone',
|
|
151
|
+
background_color: '#181825',
|
|
152
|
+
theme_color: '#1e1e2e',
|
|
153
|
+
orientation: 'any',
|
|
154
|
+
icons: [
|
|
155
|
+
{
|
|
156
|
+
src: '/__dsh_bridge__/pwa-icon.svg',
|
|
157
|
+
sizes: 'any',
|
|
158
|
+
type: 'image/svg+xml',
|
|
159
|
+
purpose: 'any maskable'
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}, null, 2);
|
|
163
|
+
|
|
164
|
+
const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
165
|
+
<defs>
|
|
166
|
+
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
167
|
+
<stop offset="0%" stop-color="#4f6ef7"/>
|
|
168
|
+
<stop offset="100%" stop-color="#24388a"/>
|
|
169
|
+
</linearGradient>
|
|
170
|
+
</defs>
|
|
171
|
+
<rect width="512" height="512" rx="128" fill="url(#g)"/>
|
|
172
|
+
<path d="M150 170 C150 140, 362 140, 362 170 L362 330 C362 360, 150 360, 150 330 Z" fill="#ffffff" fill-opacity="0.12"/>
|
|
173
|
+
<circle cx="206" cy="220" r="28" fill="#ffffff"/>
|
|
174
|
+
<circle cx="306" cy="220" r="28" fill="#ffffff"/>
|
|
175
|
+
<path d="M200 290 Q256 340 312 290" stroke="#ffffff" stroke-width="24" stroke-linecap="round" fill="none"/>
|
|
176
|
+
<rect x="236" y="90" width="40" height="60" rx="10" fill="#ffffff"/>
|
|
177
|
+
<circle cx="256" cy="80" r="16" fill="#4f6ef7"/>
|
|
178
|
+
</svg>`;
|
|
179
|
+
|
|
180
|
+
const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
181
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
182
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
183
|
+
<meta name="apple-mobile-web-app-title" content="DSH">
|
|
184
|
+
<meta name="theme-color" content="#1e1e2e">
|
|
185
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
186
|
+
<link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
|
|
187
|
+
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
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>`;
|
|
189
|
+
const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
|
|
190
|
+
|
|
191
|
+
function isCompressed(headers) {
|
|
192
|
+
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function encodeBase64Url(value) {
|
|
196
|
+
return Buffer.from(value).toString('base64url');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
|
|
201
|
+
*/
|
|
202
|
+
function getDshLoopbackCookie(targetPort) {
|
|
203
|
+
try {
|
|
204
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
205
|
+
const credPath = join(dshHome, '.credentials.yaml');
|
|
206
|
+
if (!existsSync(credPath)) return '';
|
|
207
|
+
const content = readFileSync(credPath, 'utf8');
|
|
208
|
+
const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
|
|
209
|
+
if (!match) return '';
|
|
210
|
+
const secret = Buffer.from(match[1], 'base64url');
|
|
211
|
+
|
|
212
|
+
const authority = `127.0.0.1:${targetPort}`;
|
|
213
|
+
const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
|
|
214
|
+
const issuedAt = Date.now() - 1000;
|
|
215
|
+
const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
|
|
216
|
+
const body = encodeBase64Url(Buffer.from(JSON.stringify({
|
|
217
|
+
version: 1, authority, issuedAt, expiresAt,
|
|
218
|
+
}), 'utf8'));
|
|
219
|
+
const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
|
|
220
|
+
return `${name}=v1.${body}.${sig}`;
|
|
221
|
+
} catch {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
|
|
227
|
+
function loopbackHeaders(headers, targetPort) {
|
|
228
|
+
const authority = `127.0.0.1:${targetPort}`;
|
|
229
|
+
const out = { ...headers };
|
|
230
|
+
out['host'] = authority;
|
|
231
|
+
if (out['origin']) out['origin'] = `http://${authority}`;
|
|
232
|
+
if (out['Origin']) out['Origin'] = `http://${authority}`;
|
|
233
|
+
|
|
234
|
+
// 1. 注入 DSH 本地认证签名(若有)
|
|
235
|
+
const dshCookie = getDshLoopbackCookie(targetPort);
|
|
236
|
+
if (dshCookie) {
|
|
237
|
+
const existing = out['cookie'] || out['Cookie'] || '';
|
|
238
|
+
out['cookie'] = existing ? `${existing}; ${dshCookie}` : dshCookie;
|
|
239
|
+
delete out['Cookie'];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 2. 禁用内部代理流量压缩,确保代理层拿到未压缩 HTML 以稳定注入 ownsHost 和 Polyfill
|
|
243
|
+
delete out['accept-encoding'];
|
|
244
|
+
delete out['Accept-Encoding'];
|
|
245
|
+
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* HTTP + WebSocket 代理服务器(带安全认证守门)
|
|
251
|
+
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
252
|
+
* 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
|
|
253
|
+
*/
|
|
254
|
+
class ProxyServer {
|
|
255
|
+
/**
|
|
256
|
+
* @param {object} opts
|
|
257
|
+
* @param {number} opts.localPort 监听端口
|
|
258
|
+
* @param {number} opts.targetPort 上游 DSH web(loopback) 端口
|
|
259
|
+
* @param {object} [opts.authManager] 认证管理器
|
|
260
|
+
* @param {object} [opts.logger]
|
|
261
|
+
* @param {{ key: string|Buffer, cert: string|Buffer }} [opts.tls] 若提供则启用 HTTPS(公网直连网关)
|
|
262
|
+
* @param {string} [opts.tag] 日志标签(如 '网关')
|
|
263
|
+
*/
|
|
264
|
+
constructor({ localPort, targetPort, authManager, logger, tls, tag = '' }) {
|
|
265
|
+
this.localPort = localPort;
|
|
266
|
+
this.targetPort = targetPort;
|
|
267
|
+
this.authManager = authManager;
|
|
268
|
+
this.logger = logger;
|
|
269
|
+
this.tls = tls ?? null;
|
|
270
|
+
this.tag = tag ? `[${tag}] ` : '';
|
|
271
|
+
this.server = null;
|
|
272
|
+
this.clientSockets = new Set();
|
|
273
|
+
this.activeConnections = 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async start() {
|
|
277
|
+
if (this.server) return;
|
|
278
|
+
|
|
279
|
+
const handler = (req, res) => {
|
|
280
|
+
const pathname = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';
|
|
281
|
+
|
|
282
|
+
// 0. PWA Web App Manifest 与 App 图标支持
|
|
283
|
+
if (pathname === '/manifest.webmanifest' || pathname === '/manifest.json') {
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'application/manifest+json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
|
|
285
|
+
res.end(PWA_MANIFEST);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (pathname === '/__dsh_bridge__/pwa-icon.svg' || pathname === '/apple-touch-icon.png') {
|
|
289
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
|
|
290
|
+
res.end(PWA_ICON_SVG);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 1. 处理登录 API: POST /__dsh_bridge__/login
|
|
295
|
+
if (pathname === '/__dsh_bridge__/login' && req.method === 'POST') {
|
|
296
|
+
const chunks = [];
|
|
297
|
+
req.on('data', (c) => chunks.push(c));
|
|
298
|
+
req.on('end', () => {
|
|
299
|
+
try {
|
|
300
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
301
|
+
const clientIp = req.socket?.remoteAddress || '';
|
|
302
|
+
const verify = this.authManager?.verifyPassword(body.password, clientIp);
|
|
303
|
+
if (verify?.success) {
|
|
304
|
+
const sessionToken = this.authManager.createSession();
|
|
305
|
+
res.writeHead(200, {
|
|
306
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
307
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
308
|
+
});
|
|
309
|
+
res.end(JSON.stringify({ ok: true }));
|
|
310
|
+
} else {
|
|
311
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
312
|
+
res.end(JSON.stringify({ ok: false, error: verify?.error || '访问密码错误' }));
|
|
313
|
+
}
|
|
314
|
+
} catch (e) {
|
|
315
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
316
|
+
res.end(JSON.stringify({ ok: false, error: '无效请求' }));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 2. 处理登出 API: POST /__dsh_bridge__/logout
|
|
323
|
+
if (pathname === '/__dsh_bridge__/logout' && req.method === 'POST') {
|
|
324
|
+
res.writeHead(200, {
|
|
325
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
326
|
+
'Set-Cookie': 'dsh_bridge_auth=; Path=/; HttpOnly; Max-Age=0',
|
|
327
|
+
});
|
|
328
|
+
res.end(JSON.stringify({ ok: true }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status (严格脱敏,不暴露 secretToken)
|
|
333
|
+
if (pathname === '/__dsh_bridge__/auth-status' && req.method === 'GET') {
|
|
334
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
335
|
+
res.end(JSON.stringify(this.authManager?.getPublicStatus() ?? { enabled: false }));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 3.1 本机特权 Token 签发:仅限真正物理回环连接(127.0.0.1 / ::1,严禁隧道转发流量伪造)
|
|
340
|
+
if (pathname === '/__dsh_bridge__/loopback-token') {
|
|
341
|
+
const corsHeaders = {
|
|
342
|
+
'Access-Control-Allow-Origin': '*',
|
|
343
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
344
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
345
|
+
};
|
|
346
|
+
if (req.method === 'OPTIONS') {
|
|
347
|
+
res.writeHead(204, corsHeaders);
|
|
348
|
+
res.end();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const remote = req.socket?.remoteAddress || '';
|
|
353
|
+
const isLoopback = (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1');
|
|
354
|
+
const internalTunnelHeader = req.headers?.['x-dsh-internal-tunnel'];
|
|
355
|
+
const isCustomTunnel = Boolean(isLoopback && internalTunnelHeader && internalTunnelHeader === this.authManager?.internalTunnelSecret);
|
|
356
|
+
const isCloudflare = Boolean(isLoopback && (req.headers?.['cf-ray'] || req.headers?.['cf-connecting-ip']));
|
|
357
|
+
const isPublicTunnel = isCustomTunnel || isCloudflare;
|
|
358
|
+
|
|
359
|
+
if (isLoopback && !isPublicTunnel && this.authManager) {
|
|
360
|
+
const adminToken = this.authManager.createAdminSession();
|
|
361
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders });
|
|
362
|
+
res.end(JSON.stringify({ ok: true, adminToken }));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders });
|
|
367
|
+
res.end(JSON.stringify({ ok: false, error: 'Forbidden: loopback only' }));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 4. 核心鉴权拦截
|
|
372
|
+
const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
|
|
373
|
+
|
|
374
|
+
// 4.1 若从 URL Token 认证通过:下发 Cookie 并 302 重定向到干净 URL (去掉 ?auth=)
|
|
375
|
+
if (auth.fromToken) {
|
|
376
|
+
const sessionToken = this.authManager.createSession();
|
|
377
|
+
try {
|
|
378
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
379
|
+
urlObj.searchParams.delete('auth');
|
|
380
|
+
urlObj.searchParams.delete('token');
|
|
381
|
+
const cleanPath = (urlObj.pathname || '/') + (urlObj.search || '');
|
|
382
|
+
res.writeHead(302, {
|
|
383
|
+
'Location': cleanPath,
|
|
384
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
385
|
+
});
|
|
386
|
+
res.end();
|
|
387
|
+
return;
|
|
388
|
+
} catch {
|
|
389
|
+
res.writeHead(302, {
|
|
390
|
+
'Location': '/',
|
|
391
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
392
|
+
});
|
|
393
|
+
res.end();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 4.2 若未通过认证:根据请求类型渲染 DSH 登录页或返回 401 JSON
|
|
399
|
+
if (!auth.authenticated) {
|
|
400
|
+
const accept = String(req.headers['accept'] || '');
|
|
401
|
+
const isHtml = accept.includes('text/html') || (!req.url.startsWith('/api/') && !req.url.includes('.'));
|
|
402
|
+
if (isHtml) {
|
|
403
|
+
const clientIp = req.socket?.remoteAddress || '';
|
|
404
|
+
const isLocked = this.authManager?.isIpBlocked(clientIp);
|
|
405
|
+
const html = renderLoginPage({
|
|
406
|
+
hasPassword: this.authManager?.hasPassword,
|
|
407
|
+
locked: isLocked,
|
|
408
|
+
error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
|
|
409
|
+
});
|
|
410
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
411
|
+
res.end(html);
|
|
412
|
+
return;
|
|
413
|
+
} else {
|
|
414
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
415
|
+
res.end(JSON.stringify({ error: 'unauthorized', message: '需要访问认证,请先登录' }));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 5. 认证通过:正常执行反向代理转发
|
|
421
|
+
const headers = loopbackHeaders(req.headers, this.targetPort);
|
|
422
|
+
const proxyReq = httpRequest(
|
|
423
|
+
{ host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
|
|
424
|
+
(proxyRes) => {
|
|
425
|
+
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
426
|
+
// 未压缩的 HTML 文档注入 polyfill
|
|
427
|
+
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
428
|
+
const chunks = [];
|
|
429
|
+
proxyRes.on('data', (c) => chunks.push(c));
|
|
430
|
+
proxyRes.on('end', () => {
|
|
431
|
+
let html = Buffer.concat(chunks).toString('utf8');
|
|
432
|
+
if (!html.includes(INJECT_MARK)) {
|
|
433
|
+
html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
|
|
434
|
+
}
|
|
435
|
+
const out = Buffer.from(html, 'utf8');
|
|
436
|
+
const outHeaders = { ...proxyRes.headers };
|
|
437
|
+
delete outHeaders['content-length'];
|
|
438
|
+
delete outHeaders['transfer-encoding'];
|
|
439
|
+
outHeaders['content-length'] = String(out.length);
|
|
440
|
+
res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
|
|
441
|
+
res.end(out);
|
|
442
|
+
});
|
|
443
|
+
proxyRes.on('error', () => res.destroy());
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
447
|
+
proxyRes.pipe(res);
|
|
448
|
+
res.on('close', () => proxyRes.destroy());
|
|
449
|
+
proxyRes.on('error', () => res.destroy());
|
|
450
|
+
proxyRes.on('close', () => { if (!res.writableEnded) res.destroy(); });
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
proxyReq.on('error', (err) => {
|
|
454
|
+
this.logger.error('代理请求失败: %s', err.message);
|
|
455
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
456
|
+
res.end(`dsh-bridge: 无法连接 dsh web (127.0.0.1:${this.targetPort}) — ${err.message}`);
|
|
457
|
+
});
|
|
458
|
+
req.pipe(proxyReq);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// 依据是否配置 TLS 选择 HTTPS 或 HTTP 服务器(HTTPS 用于公网直连网关)
|
|
462
|
+
this.server = this.tls ? createHttpsServer(this.tls, handler) : createServer(handler);
|
|
463
|
+
|
|
464
|
+
// WebSocket upgrade 鉴权与代理
|
|
465
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
466
|
+
const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
|
|
467
|
+
if (!auth.authenticated) {
|
|
468
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nUnauthorized\r\n');
|
|
469
|
+
socket.destroy();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const headers = loopbackHeaders(req.headers, this.targetPort);
|
|
474
|
+
const proxyReq = httpRequest({
|
|
475
|
+
host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
|
|
476
|
+
});
|
|
477
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
478
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
|
|
479
|
+
const raw = [];
|
|
480
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
481
|
+
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
482
|
+
}
|
|
483
|
+
socket.write(`${raw.join('\r\n')}\r\n\r\n`);
|
|
484
|
+
if (proxyHead?.length) socket.write(proxyHead);
|
|
485
|
+
proxySocket.pipe(socket);
|
|
486
|
+
socket.pipe(proxySocket);
|
|
487
|
+
const teardown = () => {
|
|
488
|
+
try { proxySocket.destroy(); } catch {}
|
|
489
|
+
try { socket.destroy(); } catch {}
|
|
490
|
+
};
|
|
491
|
+
proxySocket.on('close', teardown);
|
|
492
|
+
socket.on('close', teardown);
|
|
493
|
+
});
|
|
494
|
+
proxyReq.on('response', (proxyRes) => {
|
|
495
|
+
if (proxyRes.statusCode === 101) return;
|
|
496
|
+
try {
|
|
497
|
+
const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
|
|
498
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
499
|
+
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
500
|
+
}
|
|
501
|
+
socket.end(raw.join('\r\n') + '\r\n\r\n');
|
|
502
|
+
proxyRes.resume();
|
|
503
|
+
} catch { socket.destroy(); }
|
|
504
|
+
});
|
|
505
|
+
proxyReq.on('error', () => socket.destroy());
|
|
506
|
+
if (head?.length) proxyReq.write(head);
|
|
507
|
+
proxyReq.end();
|
|
508
|
+
socket.on('error', () => socket.destroy());
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// 跟踪所有连接以便 stop() 时强制关闭
|
|
512
|
+
this.server.on('connection', (sock) => {
|
|
513
|
+
this.clientSockets.add(sock);
|
|
514
|
+
sock.on('close', () => this.clientSockets.delete(sock));
|
|
515
|
+
sock.on('error', () => {});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await new Promise((resolve, reject) => {
|
|
519
|
+
this.server.once('error', reject);
|
|
520
|
+
this.server.listen(this.localPort, '0.0.0.0', () => {
|
|
521
|
+
const proto = this.tls ? 'https' : 'http';
|
|
522
|
+
this.logger.info('dsh-bridge: %s代理已启动 %s://0.0.0.0:%d -> 127.0.0.1:%d', this.tag, proto, this.localPort, this.targetPort);
|
|
523
|
+
resolve();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async stop() {
|
|
529
|
+
if (!this.server) return;
|
|
530
|
+
for (const s of this.clientSockets) { try { s.destroy(); } catch {} }
|
|
531
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
532
|
+
this.server = null;
|
|
533
|
+
this.clientSockets.clear();
|
|
534
|
+
this.activeConnections = 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
get port() {
|
|
538
|
+
return this.localPort;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Bridge Service
|
|
544
|
+
*/
|
|
545
|
+
class BridgeService {
|
|
546
|
+
constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, lanConfig, gatewayConfig, authManager, onPersist, logger }) {
|
|
547
|
+
this.dshPort = dshPort;
|
|
548
|
+
this.proxyPort = proxyPort;
|
|
549
|
+
this.home = home;
|
|
550
|
+
this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
|
|
551
|
+
this.customTunnelConfig = customTunnelConfig ?? null;
|
|
552
|
+
this.selectedLanIp = lanConfig?.selectedIp ?? null;
|
|
553
|
+
this.gatewayConfig = gatewayConfig ?? null;
|
|
554
|
+
this.authManager = authManager ?? null;
|
|
555
|
+
this.onPersist = onPersist ?? null;
|
|
556
|
+
this.logger = logger;
|
|
557
|
+
|
|
558
|
+
this.qrCache = new QrCache();
|
|
559
|
+
this.proxy = null;
|
|
560
|
+
|
|
561
|
+
this.customTunnel = null;
|
|
562
|
+
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
563
|
+
|
|
564
|
+
this.cloudflared = null;
|
|
565
|
+
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
566
|
+
|
|
567
|
+
// 公网直连网关(0.0.0.0:port 自带 HTTPS 自签证书 + 强制 public_only 门禁)
|
|
568
|
+
this.gateway = null;
|
|
569
|
+
this.gatewayAuth = null; // 网关专用认证实例:always enabled + scope=all,仅环回可免密
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async setLanIp({ ip } = {}) {
|
|
573
|
+
const trimmed = ip ? String(ip).trim() : null;
|
|
574
|
+
this.selectedLanIp = trimmed || null;
|
|
575
|
+
await this.onPersist?.({ lan: { selectedIp: this.selectedLanIp } });
|
|
576
|
+
this.logger?.info('局域网选定 IP 更新为: %s', this.selectedLanIp || '自动推荐');
|
|
577
|
+
return this.getStatus();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async startProxy() {
|
|
581
|
+
if (this.proxy) return this.proxy;
|
|
582
|
+
|
|
583
|
+
this.proxy = new ProxyServer({
|
|
584
|
+
localPort: this.proxyPort,
|
|
585
|
+
targetPort: this.dshPort,
|
|
586
|
+
authManager: this.authManager,
|
|
587
|
+
logger: this.logger,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await this.proxy.start();
|
|
591
|
+
return this.proxy;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async getStatus({ adminAuthValid = false } = {}) {
|
|
595
|
+
const allInterfaces = listAllLanIPv4();
|
|
596
|
+
const isSelectedValid = Boolean(this.selectedLanIp && allInterfaces.some(i => i.address === this.selectedLanIp));
|
|
597
|
+
const lanIp = isSelectedValid ? this.selectedLanIp : selectLanIPv4();
|
|
598
|
+
const token = adminAuthValid ? this.authManager?.secretToken : null;
|
|
599
|
+
const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
|
|
600
|
+
|
|
601
|
+
const isLanProtected = isAuthEnabled && this.authManager?.scope !== 'public_only';
|
|
602
|
+
const isPublicProtected = isAuthEnabled && this.authManager?.scope !== 'lan_only';
|
|
603
|
+
|
|
604
|
+
const appendToken = (url, shouldAppend) => {
|
|
605
|
+
if (!url || !shouldAppend || !token) return url;
|
|
606
|
+
try {
|
|
607
|
+
const u = new URL(url);
|
|
608
|
+
u.searchParams.set('auth', token);
|
|
609
|
+
return u.toString();
|
|
610
|
+
} catch {
|
|
611
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
612
|
+
return `${url}${sep}auth=${encodeURIComponent(token)}`;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const baseLanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
|
|
617
|
+
const lanUrl = appendToken(baseLanUrl, isLanProtected);
|
|
618
|
+
|
|
619
|
+
const baseCloudflaredUrl = this.cloudflared?.url || null;
|
|
620
|
+
const cloudflaredUrl = appendToken(baseCloudflaredUrl, isPublicProtected);
|
|
621
|
+
|
|
622
|
+
const baseCustomUrl = this.customTunnel?.publicUrl || null;
|
|
623
|
+
const customUrl = appendToken(baseCustomUrl, isPublicProtected);
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
version: VERSION,
|
|
627
|
+
|
|
628
|
+
auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
|
|
629
|
+
|
|
630
|
+
proxy: {
|
|
631
|
+
running: !!this.proxy,
|
|
632
|
+
port: this.proxyPort,
|
|
633
|
+
activeConnections: this.proxy?.activeConnections ?? 0,
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
lan: {
|
|
637
|
+
ip: lanIp,
|
|
638
|
+
selectedIp: this.selectedLanIp || '',
|
|
639
|
+
interfaces: allInterfaces,
|
|
640
|
+
url: lanUrl,
|
|
641
|
+
rawUrl: baseLanUrl,
|
|
642
|
+
qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
cloudflared: {
|
|
646
|
+
running: !!this.cloudflared,
|
|
647
|
+
url: cloudflaredUrl,
|
|
648
|
+
rawUrl: baseCloudflaredUrl,
|
|
649
|
+
qr: cloudflaredUrl
|
|
650
|
+
? await this.qrCache.get(cloudflaredUrl)
|
|
651
|
+
: null,
|
|
652
|
+
state: this.cloudflaredState,
|
|
653
|
+
tokenConfigured: !!this.cloudflaredConfig?.token,
|
|
654
|
+
token: adminAuthValid ? (this.cloudflaredConfig?.token || '') : (this.cloudflaredConfig?.token ? '******' : ''),
|
|
655
|
+
hostname: this.cloudflaredConfig?.hostname || '',
|
|
656
|
+
autoStart: Boolean(this.cloudflaredConfig?.autoStart),
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
customTunnel: {
|
|
660
|
+
configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
|
|
661
|
+
serverUrl: this.customTunnelConfig?.serverUrl ?? '',
|
|
662
|
+
running: !!this.customTunnel?.connected,
|
|
663
|
+
url: customUrl,
|
|
664
|
+
rawUrl: baseCustomUrl,
|
|
665
|
+
qr: customUrl
|
|
666
|
+
? await this.qrCache.get(customUrl)
|
|
667
|
+
: null,
|
|
668
|
+
state: this.customTunnelState,
|
|
669
|
+
autoStart: Boolean(this.customTunnelConfig?.autoStart),
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
// 公网直连网关(0.0.0.0:port HTTPS + 强制登录门禁)
|
|
673
|
+
gateway: this.gatewayStatus(),
|
|
674
|
+
|
|
675
|
+
// 轻量摘要,供 UI Tab 状态点使用(完整状态由 wechatGetStatus 提供)
|
|
676
|
+
wechat: this.wechat ? { status: this.wechat.gateway?.status ?? 'idle' } : null,
|
|
677
|
+
|
|
678
|
+
// 宿主系统运行监控指标
|
|
679
|
+
system: this.getSystemMetrics(),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async saveCloudflaredConfig({ token, hostname }) {
|
|
684
|
+
this.cloudflaredConfig = {
|
|
685
|
+
...(this.cloudflaredConfig ?? {}),
|
|
686
|
+
token: token ? String(token).trim() : '',
|
|
687
|
+
hostname: hostname ? String(hostname).trim() : '',
|
|
688
|
+
};
|
|
689
|
+
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async setTunnelAutoStart({ tunnel, autoStart }) {
|
|
693
|
+
const isAuto = Boolean(autoStart);
|
|
694
|
+
if (tunnel === 'cloudflared') {
|
|
695
|
+
this.cloudflaredConfig = {
|
|
696
|
+
...(this.cloudflaredConfig ?? {}),
|
|
697
|
+
autoStart: isAuto,
|
|
698
|
+
};
|
|
699
|
+
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
700
|
+
} else if (tunnel === 'customTunnel' || tunnel === 'custom') {
|
|
701
|
+
this.customTunnelConfig = {
|
|
702
|
+
...(this.customTunnelConfig ?? {}),
|
|
703
|
+
autoStart: isAuto,
|
|
704
|
+
};
|
|
705
|
+
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async startCustomTunnel({ autoStart = true } = {}) {
|
|
710
|
+
if (this.customTunnel) {
|
|
711
|
+
throw new Error('自建隧道已在运行');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const serverUrl = this.customTunnelConfig?.serverUrl;
|
|
715
|
+
const accessToken = this.customTunnelConfig?.accessToken;
|
|
716
|
+
|
|
717
|
+
if (!serverUrl || !accessToken) {
|
|
718
|
+
throw new Error('缺少配置:请在控制台配置 customTunnel.serverUrl 和 customTunnel.accessToken');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
this.customTunnelConfig = {
|
|
722
|
+
...(this.customTunnelConfig ?? {}),
|
|
723
|
+
autoStart: Boolean(autoStart),
|
|
724
|
+
};
|
|
725
|
+
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
726
|
+
|
|
727
|
+
this.customTunnel = new CustomTunnelClient({
|
|
728
|
+
serverUrl,
|
|
729
|
+
accessToken,
|
|
730
|
+
localPort: this.proxyPort,
|
|
731
|
+
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
732
|
+
onStateChange: (state) => {
|
|
733
|
+
this.customTunnelState = state;
|
|
734
|
+
},
|
|
735
|
+
logger: this.logger,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
await this.customTunnel.connect();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async stopCustomTunnel() {
|
|
742
|
+
if (this.customTunnel) {
|
|
743
|
+
this.customTunnel.disconnect();
|
|
744
|
+
this.customTunnel = null;
|
|
745
|
+
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
746
|
+
}
|
|
747
|
+
this.customTunnelConfig = {
|
|
748
|
+
...(this.customTunnelConfig ?? {}),
|
|
749
|
+
autoStart: false,
|
|
750
|
+
};
|
|
751
|
+
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async startCloudflared({ autoStart = true } = {}) {
|
|
755
|
+
if (this.cloudflared) {
|
|
756
|
+
throw new Error('Cloudflare 隧道已在运行');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
this.cloudflaredConfig = {
|
|
760
|
+
...(this.cloudflaredConfig ?? {}),
|
|
761
|
+
autoStart: Boolean(autoStart),
|
|
762
|
+
};
|
|
763
|
+
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
764
|
+
|
|
765
|
+
this.cloudflaredState = { phase: 'connecting', detail: '正在初始化...' };
|
|
766
|
+
this.cloudflared = new CloudflaredManager({
|
|
767
|
+
port: this.proxyPort,
|
|
768
|
+
home: this.home,
|
|
769
|
+
token: this.cloudflaredConfig?.token,
|
|
770
|
+
hostname: this.cloudflaredConfig?.hostname,
|
|
771
|
+
onStateChange: (state) => {
|
|
772
|
+
this.cloudflaredState = state;
|
|
773
|
+
// 出错时自动清理,让用户可以重新开启
|
|
774
|
+
if (state.phase === 'error') {
|
|
775
|
+
this.cloudflared = null;
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
logger: this.logger,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// 非阻塞启动,立即返回——下载/连接进度通过 onStateChange 推送
|
|
782
|
+
this.cloudflared.start();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async stopCloudflared() {
|
|
786
|
+
if (this.cloudflared) {
|
|
787
|
+
this.cloudflared.stop();
|
|
788
|
+
this.cloudflared = null;
|
|
789
|
+
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
790
|
+
}
|
|
791
|
+
this.cloudflaredConfig = {
|
|
792
|
+
...(this.cloudflaredConfig ?? {}),
|
|
793
|
+
autoStart: false,
|
|
794
|
+
};
|
|
795
|
+
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制
|
|
799
|
+
async resetCloudflared() {
|
|
800
|
+
await this.stopCloudflared();
|
|
801
|
+
const binDir = join(this.home ?? join(homedir(), '.dsh-bridge'), 'bin');
|
|
802
|
+
const candidates = ['cloudflared.exe', 'cloudflared'];
|
|
803
|
+
for (const name of candidates) {
|
|
804
|
+
const p = join(binDir, name);
|
|
805
|
+
try { await unlink(p); } catch {}
|
|
806
|
+
}
|
|
807
|
+
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---- 公网直连网关(0.0.0.0:port 自带 HTTPS 自签证书 + 强制登录门禁)----
|
|
811
|
+
|
|
812
|
+
getGatewayPort() {
|
|
813
|
+
return Math.min(65535, Math.max(1024, Number(this.gatewayConfig?.port) || DEFAULT_GATEWAY_PORT));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async saveGatewayConfig({ port, autoStart } = {}) {
|
|
817
|
+
const next = {
|
|
818
|
+
...(this.gatewayConfig ?? {}),
|
|
819
|
+
port: this.getGatewayPortWith(port),
|
|
820
|
+
autoStart: autoStart !== undefined ? Boolean(autoStart) : Boolean(this.gatewayConfig?.autoStart),
|
|
821
|
+
};
|
|
822
|
+
this.gatewayConfig = next;
|
|
823
|
+
await this.onPersist?.({ gateway: this.gatewayConfig });
|
|
824
|
+
return this.getStatus();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
getGatewayPortWith(port) {
|
|
828
|
+
const p = port === undefined ? null : Number(port);
|
|
829
|
+
if (p === null || !Number.isFinite(p)) return this.getGatewayPort();
|
|
830
|
+
return Math.min(65535, Math.max(1024, Math.round(p)));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async setGatewayAutoStart({ autoStart } = {}) {
|
|
834
|
+
this.gatewayConfig = {
|
|
835
|
+
...(this.gatewayConfig ?? {}),
|
|
836
|
+
autoStart: Boolean(autoStart),
|
|
837
|
+
};
|
|
838
|
+
await this.onPersist?.({ gateway: this.gatewayConfig });
|
|
839
|
+
return this.getStatus();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// 刷新网关认证:把宿主最新访问密码/Secure Token 同步到网关,但强制 enabled=true + scope=all
|
|
843
|
+
_refreshGatewayAuth() {
|
|
844
|
+
if (!this.gatewayAuth) {
|
|
845
|
+
this.gatewayAuth = new AuthManager({
|
|
846
|
+
config: {
|
|
847
|
+
enabled: true,
|
|
848
|
+
mode: this.authManager?.mode || 'token_and_password',
|
|
849
|
+
scope: 'all',
|
|
850
|
+
adminPolicy: this.authManager?.adminPolicy || 'password_unlock',
|
|
851
|
+
allowLoopback: true,
|
|
852
|
+
},
|
|
853
|
+
logger: this.logger,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const g = this.gatewayAuth;
|
|
857
|
+
g.enabled = true;
|
|
858
|
+
g.scope = 'all';
|
|
859
|
+
g.allowLoopback = true;
|
|
860
|
+
g.mode = this.authManager?.mode || g.mode;
|
|
861
|
+
g.adminPolicy = this.authManager?.adminPolicy || g.adminPolicy;
|
|
862
|
+
g.passwordHash = this.authManager?.passwordHash || '';
|
|
863
|
+
g.passwordSalt = this.authManager?.passwordSalt || '';
|
|
864
|
+
g.adminPasswordHash = this.authManager?.adminPasswordHash || '';
|
|
865
|
+
g.adminPasswordSalt = this.authManager?.adminPasswordSalt || '';
|
|
866
|
+
if (this.authManager?.secretToken) g.secretToken = this.authManager.secretToken;
|
|
867
|
+
return g;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async startGateway() {
|
|
871
|
+
if (this.gateway) return this.gateway;
|
|
872
|
+
|
|
873
|
+
const port = this.getGatewayPort();
|
|
874
|
+
this.gatewayConfig = { ...(this.gatewayConfig ?? {}), port };
|
|
875
|
+
await this.onPersist?.({ gateway: this.gatewayConfig });
|
|
876
|
+
|
|
877
|
+
const certDir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-bridge', 'certs', `gateway-${port}`);
|
|
878
|
+
const certFile = join(certDir, 'cert.pem');
|
|
879
|
+
const keyFile = join(certDir, 'key.pem');
|
|
880
|
+
|
|
881
|
+
let tls;
|
|
882
|
+
try {
|
|
883
|
+
tls = await getOrCreateSelfSignedCert({ certFile, keyFile, logger: this.logger });
|
|
884
|
+
} catch (err) {
|
|
885
|
+
throw new Error(`无法生成 HTTPS 自签证书: ${err?.message ?? err}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const gatewayAuth = this._refreshGatewayAuth();
|
|
889
|
+
this.gateway = new ProxyServer({
|
|
890
|
+
localPort: port,
|
|
891
|
+
targetPort: this.dshPort,
|
|
892
|
+
authManager: gatewayAuth,
|
|
893
|
+
logger: this.logger,
|
|
894
|
+
tls: { key: tls.key, cert: tls.cert },
|
|
895
|
+
tag: '公网直连',
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
await this.gateway.start();
|
|
899
|
+
this.logger?.info?.('dsh-bridge: 公网直连网关已启用 0.0.0.0:%d (HTTPS + 强制登录门禁)', port);
|
|
900
|
+
return this.gateway;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async stopGateway() {
|
|
904
|
+
if (this.gateway) {
|
|
905
|
+
await this.gateway.stop();
|
|
906
|
+
this.gateway = null;
|
|
907
|
+
}
|
|
908
|
+
this.gatewayConfig = { ...(this.gatewayConfig ?? {}), autoStart: false };
|
|
909
|
+
await this.onPersist?.({ gateway: this.gatewayConfig });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
gatewayStatus() {
|
|
913
|
+
const port = this.getGatewayPort();
|
|
914
|
+
return {
|
|
915
|
+
configured: Boolean(this.gatewayConfig),
|
|
916
|
+
port,
|
|
917
|
+
running: Boolean(this.gateway),
|
|
918
|
+
autoStart: Boolean(this.gatewayConfig?.autoStart),
|
|
919
|
+
forcedLogin: true,
|
|
920
|
+
havePassword: Boolean(this.authManager?.hasPassword),
|
|
921
|
+
// 直连网关地址取决于宿主机公网 IP/域名,本机仅能给出端口说明;启动后根据实际可达地址访问
|
|
922
|
+
url: this.gateway ? `https://<公网IP或域名>${port === 443 ? '' : `:${port}`}` : null,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
async checkVersion() {
|
|
926
|
+
const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
|
|
927
|
+
const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
|
|
928
|
+
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
929
|
+
const chunks = [];
|
|
930
|
+
res.on('data', (c) => chunks.push(c));
|
|
931
|
+
res.on('end', () => {
|
|
932
|
+
try {
|
|
933
|
+
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
934
|
+
resolve({
|
|
935
|
+
version: data.version ?? null,
|
|
936
|
+
releaseNotes: data.releaseNotes ?? data.description ?? null,
|
|
937
|
+
});
|
|
938
|
+
} catch (e) {
|
|
939
|
+
reject(e);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
req.on('error', reject);
|
|
944
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
try {
|
|
948
|
+
const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
|
|
949
|
+
.catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
|
|
950
|
+
return {
|
|
951
|
+
current: VERSION,
|
|
952
|
+
latest: latestData?.version ?? null,
|
|
953
|
+
releaseNotes: latestData?.releaseNotes ?? null,
|
|
954
|
+
};
|
|
955
|
+
} catch (e) {
|
|
956
|
+
return { current: VERSION, latest: null, error: e.message ?? '检查失败' };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
|
|
961
|
+
async upgradePlugin({ version } = {}) {
|
|
962
|
+
const targetVersion = version ? String(version).trim() : 'latest';
|
|
963
|
+
// 严格 SemVer 白名单正则校验
|
|
964
|
+
if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
|
|
965
|
+
return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
|
|
966
|
+
}
|
|
967
|
+
const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
|
|
968
|
+
const isWin = process.platform === 'win32';
|
|
969
|
+
|
|
970
|
+
// 自动构建包含 Homebrew / NVM / Node 兄弟目录的全量 PATH 环境变量
|
|
971
|
+
const nodeDir = dirname(process.execPath);
|
|
972
|
+
const home = homedir();
|
|
973
|
+
const extraPaths = isWin ? [
|
|
974
|
+
nodeDir,
|
|
975
|
+
] : [
|
|
976
|
+
nodeDir,
|
|
977
|
+
'/opt/homebrew/bin',
|
|
978
|
+
'/opt/homebrew/sbin',
|
|
979
|
+
'/usr/local/bin',
|
|
980
|
+
'/usr/bin',
|
|
981
|
+
'/bin',
|
|
982
|
+
join(home, '.nvm/current/bin'),
|
|
983
|
+
join(home, '.fnm/current/bin'),
|
|
984
|
+
join(home, '.local/bin'),
|
|
985
|
+
join(home, '.cargo/bin'),
|
|
986
|
+
];
|
|
987
|
+
|
|
988
|
+
const separator = isWin ? ';' : ':';
|
|
989
|
+
const existingPath = process.env.PATH || process.env.Path || '';
|
|
990
|
+
const augmentedEnv = {
|
|
991
|
+
...process.env,
|
|
992
|
+
PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
|
|
993
|
+
};
|
|
994
|
+
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
995
|
+
|
|
996
|
+
// 寻找与当前 node 配对的 npm/npx 绝对路径
|
|
997
|
+
const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
|
|
998
|
+
const siblingNpx = join(nodeDir, isWin ? 'npx.cmd' : 'npx');
|
|
999
|
+
|
|
1000
|
+
const tasks = [
|
|
1001
|
+
{ cmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
|
|
1002
|
+
{ cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
|
|
1003
|
+
{ cmd: existsSync(siblingNpm) ? siblingNpm : 'npm', args: ['install', pkgSpec] },
|
|
1004
|
+
];
|
|
1005
|
+
|
|
1006
|
+
let lastError = null;
|
|
1007
|
+
|
|
1008
|
+
for (const task of tasks) {
|
|
1009
|
+
try {
|
|
1010
|
+
const res = await new Promise((resolve, reject) => {
|
|
1011
|
+
let cp;
|
|
1012
|
+
try {
|
|
1013
|
+
cp = spawn(task.cmd, task.args, {
|
|
1014
|
+
windowsHide: true,
|
|
1015
|
+
shell: true,
|
|
1016
|
+
env: augmentedEnv,
|
|
1017
|
+
timeout: 120000,
|
|
1018
|
+
});
|
|
1019
|
+
} catch (spawnErr) {
|
|
1020
|
+
return reject(spawnErr);
|
|
1021
|
+
}
|
|
1022
|
+
let stdout = '';
|
|
1023
|
+
let stderr = '';
|
|
1024
|
+
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
1025
|
+
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
1026
|
+
cp.on('error', (err) => {
|
|
1027
|
+
reject(err);
|
|
1028
|
+
});
|
|
1029
|
+
cp.on('close', (code) => {
|
|
1030
|
+
if (code === 0) {
|
|
1031
|
+
resolve({ stdout, stderr });
|
|
1032
|
+
} else {
|
|
1033
|
+
reject(new Error(stderr || stdout || `进程退出码 ${code}`));
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
const output = res.stdout || res.stderr || '升级成功';
|
|
1039
|
+
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
lastError = err;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
|
|
1049
|
+
async restartDsh() {
|
|
1050
|
+
this.logger?.info('收到 DSH 重启请求,正在调度重启...');
|
|
1051
|
+
setTimeout(() => {
|
|
1052
|
+
try {
|
|
1053
|
+
if (process.env.DSH_DAEMON || process.env.PM2_HOME) {
|
|
1054
|
+
process.exit(0);
|
|
1055
|
+
} else {
|
|
1056
|
+
// 常规 Node/CLI 模式:派生与当前参数一致的独立后台子进程并退出当前进程
|
|
1057
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
1058
|
+
cwd: process.cwd(),
|
|
1059
|
+
env: process.env,
|
|
1060
|
+
detached: true,
|
|
1061
|
+
stdio: 'ignore',
|
|
1062
|
+
windowsHide: false,
|
|
1063
|
+
});
|
|
1064
|
+
child.unref();
|
|
1065
|
+
process.exit(0);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
this.logger?.error('派生重启进程失败: %s,执行直接退出', err.message);
|
|
1069
|
+
process.exit(0);
|
|
1070
|
+
}
|
|
1071
|
+
}, 600);
|
|
1072
|
+
|
|
1073
|
+
return { ok: true, message: 'DSH 服务正在重启中,前端将在几秒后自动重新连接…' };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
getSystemMetrics() {
|
|
1077
|
+
try {
|
|
1078
|
+
const totalMem = totalmem();
|
|
1079
|
+
const freeMem = freemem();
|
|
1080
|
+
const usedMem = totalMem - freeMem;
|
|
1081
|
+
const memUsage = process.memoryUsage();
|
|
1082
|
+
const cpusList = cpus() || [];
|
|
1083
|
+
const cpuCount = cpusList.length;
|
|
1084
|
+
const cpuModel = cpusList[0]?.model || 'Generic CPU';
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
os: {
|
|
1088
|
+
platform: platform(),
|
|
1089
|
+
arch: arch(),
|
|
1090
|
+
release: release(),
|
|
1091
|
+
hostname: hostname(),
|
|
1092
|
+
nodeVersion: process.version,
|
|
1093
|
+
},
|
|
1094
|
+
uptime: {
|
|
1095
|
+
processSec: Math.floor(process.uptime()),
|
|
1096
|
+
systemSec: Math.floor(uptime()),
|
|
1097
|
+
},
|
|
1098
|
+
cpu: {
|
|
1099
|
+
model: cpuModel,
|
|
1100
|
+
cores: cpuCount,
|
|
1101
|
+
loadAvg: typeof loadavg === 'function' ? loadavg() : [0, 0, 0],
|
|
1102
|
+
},
|
|
1103
|
+
memory: {
|
|
1104
|
+
totalBytes: totalMem,
|
|
1105
|
+
freeBytes: freeMem,
|
|
1106
|
+
usedBytes: usedMem,
|
|
1107
|
+
usedPercent: Math.round((usedMem / totalMem) * 100),
|
|
1108
|
+
processHeapUsed: memUsage.heapUsed,
|
|
1109
|
+
processRss: memUsage.rss,
|
|
1110
|
+
},
|
|
1111
|
+
};
|
|
1112
|
+
} catch {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// 获取当前所有已注册的工作区
|
|
1118
|
+
async getWorkspaces() {
|
|
1119
|
+
try {
|
|
1120
|
+
const list = await this.ctx?.workspaceRegistry?.list?.() ?? [];
|
|
1121
|
+
const out = [];
|
|
1122
|
+
for (const ws of list) {
|
|
1123
|
+
if (ws && ws.path) {
|
|
1124
|
+
out.push({
|
|
1125
|
+
id: ws.id,
|
|
1126
|
+
title: ws.title ?? basename(ws.path),
|
|
1127
|
+
path: ws.path,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return out.sort((a, b) => String(a.path).localeCompare(String(b.path)));
|
|
1132
|
+
} catch {
|
|
1133
|
+
return [];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// 远程添加工作区目录到 DSH 体系
|
|
1138
|
+
async addWorkspace(workspacePath) {
|
|
1139
|
+
if (!workspacePath || typeof workspacePath !== 'string') {
|
|
1140
|
+
return { ok: false, error: '缺少工作区目录路径' };
|
|
1141
|
+
}
|
|
1142
|
+
const safetyCheck = await isSafeWorkspacePath(workspacePath);
|
|
1143
|
+
if (!safetyCheck.valid) {
|
|
1144
|
+
return { ok: false, error: safetyCheck.error || '路径安全校验未通过' };
|
|
1145
|
+
}
|
|
1146
|
+
const resolved = safetyCheck.path;
|
|
1147
|
+
|
|
1148
|
+
const title = basename(resolved) || resolved;
|
|
1149
|
+
let added = false;
|
|
1150
|
+
let workspaceId = null;
|
|
1151
|
+
|
|
1152
|
+
if (this.ctx?.workspaceRegistry) {
|
|
1153
|
+
if (typeof this.ctx.workspaceRegistry.create === 'function') {
|
|
1154
|
+
try {
|
|
1155
|
+
const entity = await this.ctx.workspaceRegistry.create(resolved, title);
|
|
1156
|
+
added = true;
|
|
1157
|
+
workspaceId = entity?.id ?? null;
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
this.logger?.warn?.('workspaceRegistry.create 失败: %s', e.message);
|
|
1160
|
+
}
|
|
1161
|
+
} else if (typeof this.ctx.workspaceRegistry.add === 'function') {
|
|
1162
|
+
try {
|
|
1163
|
+
const res = await this.ctx.workspaceRegistry.add({ path: resolved, title });
|
|
1164
|
+
added = true;
|
|
1165
|
+
workspaceId = res?.id ?? null;
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
this.logger?.warn?.('workspaceRegistry.add 失败: %s', e.message);
|
|
1168
|
+
}
|
|
1169
|
+
} else if (typeof this.ctx.workspaceRegistry.register === 'function') {
|
|
1170
|
+
try {
|
|
1171
|
+
const res = await this.ctx.workspaceRegistry.register({ path: resolved, title });
|
|
1172
|
+
added = true;
|
|
1173
|
+
workspaceId = res?.id ?? null;
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
this.logger?.warn?.('workspaceRegistry.register 失败: %s', e.message);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const list = await this.getWorkspaces();
|
|
1181
|
+
if (!workspaceId) {
|
|
1182
|
+
const match = list.find(w => w.path === resolved || (w.path && w.path.toLowerCase() === resolved.toLowerCase()));
|
|
1183
|
+
if (match) workspaceId = match.id;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
let sessionId = null;
|
|
1187
|
+
if (this.ctx?.sessions && typeof this.ctx.sessions.create === 'function') {
|
|
1188
|
+
try {
|
|
1189
|
+
const session = this.ctx.sessions.create(undefined, { meta: { cwd: resolved } });
|
|
1190
|
+
if (session?.id) {
|
|
1191
|
+
sessionId = session.id;
|
|
1192
|
+
if (workspaceId && this.ctx?.workspaceRegistry?.get) {
|
|
1193
|
+
const entity = this.ctx.workspaceRegistry.get(workspaceId);
|
|
1194
|
+
if (entity && typeof entity.attachSession === 'function') {
|
|
1195
|
+
await entity.attachSession(session.id).catch(() => {});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
this.logger?.debug?.('sessions.create 初始化 session 提示: %s', e.message);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
path: resolved,
|
|
1207
|
+
title,
|
|
1208
|
+
workspaceId,
|
|
1209
|
+
sessionId,
|
|
1210
|
+
workspaces: list,
|
|
1211
|
+
registered: added
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// 远程目录列表浏览与常用路径推荐
|
|
1216
|
+
async listRemoteDirectories(targetPath) {
|
|
1217
|
+
const isWin = process.platform === 'win32';
|
|
1218
|
+
const home = homedir();
|
|
1219
|
+
|
|
1220
|
+
// 1. 获取快速访问常用根目录
|
|
1221
|
+
const roots = [
|
|
1222
|
+
{ name: '🏠 用户主目录', path: home },
|
|
1223
|
+
];
|
|
1224
|
+
const commonSubdirs = [
|
|
1225
|
+
{ name: '💻 桌面', sub: 'Desktop' },
|
|
1226
|
+
{ name: '📁 文档', sub: 'Documents' },
|
|
1227
|
+
{ name: '📥 下载', sub: 'Downloads' },
|
|
1228
|
+
{ name: '💡 IdeaProjects', sub: 'IdeaProjects' },
|
|
1229
|
+
{ name: '🔨 Projects', sub: 'Projects' },
|
|
1230
|
+
{ name: '📦 workspace', sub: 'workspace' },
|
|
1231
|
+
{ name: '💻 code', sub: 'code' },
|
|
1232
|
+
{ name: '💻 src', sub: 'src' },
|
|
1233
|
+
];
|
|
1234
|
+
for (const item of commonSubdirs) {
|
|
1235
|
+
const fullPath = join(home, item.sub);
|
|
1236
|
+
try {
|
|
1237
|
+
const s = await stat(fullPath);
|
|
1238
|
+
if (s.isDirectory()) {
|
|
1239
|
+
roots.push({ name: item.name, path: fullPath });
|
|
1240
|
+
}
|
|
1241
|
+
} catch {}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// 2. Windows 盘符探测
|
|
1245
|
+
const drives = [];
|
|
1246
|
+
if (isWin) {
|
|
1247
|
+
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
1248
|
+
for (const letter of letters) {
|
|
1249
|
+
const driveRoot = `${letter}:\\`;
|
|
1250
|
+
try {
|
|
1251
|
+
await access(driveRoot);
|
|
1252
|
+
drives.push({ name: `${letter}: 盘`, path: driveRoot });
|
|
1253
|
+
} catch {}
|
|
1254
|
+
}
|
|
1255
|
+
if (drives.length === 0) drives.push({ name: 'C: 盘', path: 'C:\\' });
|
|
1256
|
+
} else {
|
|
1257
|
+
drives.push({ name: '根目录 /', path: '/' });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// 3. 解析当前请求路径并进行安全校验
|
|
1261
|
+
let rawTarget = targetPath && typeof targetPath === 'string' ? targetPath.trim() : '';
|
|
1262
|
+
if (isWin && /^[A-Za-z]:$/.test(rawTarget)) {
|
|
1263
|
+
rawTarget = `${rawTarget}\\`;
|
|
1264
|
+
}
|
|
1265
|
+
let candidatePath = rawTarget ? resolve(rawTarget) : home;
|
|
1266
|
+
|
|
1267
|
+
// 安全校验:遇非法或黑名单目录时安全回退至用户主目录
|
|
1268
|
+
let currentPath = home;
|
|
1269
|
+
const pathCheck = await isSafeWorkspacePath(candidatePath);
|
|
1270
|
+
if (pathCheck.valid && pathCheck.path) {
|
|
1271
|
+
currentPath = pathCheck.path;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// 4. 读取子文件夹列表(过滤敏感目录与不安全软链接)
|
|
1275
|
+
const entries = [];
|
|
1276
|
+
let readError = null;
|
|
1277
|
+
try {
|
|
1278
|
+
const dirents = await readdir(currentPath, { withFileTypes: true });
|
|
1279
|
+
for (const d of dirents) {
|
|
1280
|
+
if (isSensitiveFolderName(d.name)) continue;
|
|
1281
|
+
|
|
1282
|
+
let isDir = d.isDirectory();
|
|
1283
|
+
const targetEntryPath = join(currentPath, d.name);
|
|
1284
|
+
|
|
1285
|
+
// 如果是符号链接,安全探测其真实目标
|
|
1286
|
+
if (d.isSymbolicLink()) {
|
|
1287
|
+
try {
|
|
1288
|
+
const symCheck = await isSafeWorkspacePath(targetEntryPath);
|
|
1289
|
+
if (!symCheck.valid) continue;
|
|
1290
|
+
isDir = true;
|
|
1291
|
+
} catch {
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
if (isDir) {
|
|
1297
|
+
entries.push({
|
|
1298
|
+
name: d.name,
|
|
1299
|
+
path: targetEntryPath,
|
|
1300
|
+
isDirectory: true,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
readError = err.message;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
1309
|
+
|
|
1310
|
+
const parentPath = dirname(currentPath) !== currentPath ? dirname(currentPath) : null;
|
|
1311
|
+
|
|
1312
|
+
// 5. 生成结构化面包屑导航路径
|
|
1313
|
+
const breadcrumbs = [];
|
|
1314
|
+
if (isWin) {
|
|
1315
|
+
const match = currentPath.match(/^([A-Za-z]:)(?:\\(.*))?$/);
|
|
1316
|
+
if (match) {
|
|
1317
|
+
const driveLetter = match[1];
|
|
1318
|
+
const rest = match[2] || '';
|
|
1319
|
+
breadcrumbs.push({ name: `${driveLetter}`, path: `${driveLetter}\\` });
|
|
1320
|
+
if (rest) {
|
|
1321
|
+
const parts = rest.split('\\').filter(Boolean);
|
|
1322
|
+
let curr = `${driveLetter}\\`;
|
|
1323
|
+
for (const p of parts) {
|
|
1324
|
+
curr = join(curr, p);
|
|
1325
|
+
breadcrumbs.push({ name: p, path: curr });
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} else {
|
|
1329
|
+
breadcrumbs.push({ name: currentPath, path: currentPath });
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
breadcrumbs.push({ name: '根目录 /', path: '/' });
|
|
1333
|
+
const parts = currentPath.split('/').filter(Boolean);
|
|
1334
|
+
let curr = '/';
|
|
1335
|
+
for (const p of parts) {
|
|
1336
|
+
curr = join(curr, p);
|
|
1337
|
+
breadcrumbs.push({ name: p, path: curr });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// 6. 获取当前已注册的工作区作为快捷参考
|
|
1342
|
+
const currentWorkspaces = await this.getWorkspaces();
|
|
1343
|
+
|
|
1344
|
+
return {
|
|
1345
|
+
ok: !readError,
|
|
1346
|
+
error: readError ? `读取文件夹失败: ${readError}` : undefined,
|
|
1347
|
+
currentPath,
|
|
1348
|
+
parentPath,
|
|
1349
|
+
breadcrumbs,
|
|
1350
|
+
entries: entries.slice(0, 150),
|
|
1351
|
+
totalEntries: entries.length,
|
|
1352
|
+
roots,
|
|
1353
|
+
drives,
|
|
1354
|
+
workspaces: currentWorkspaces,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async diagnoseNetwork() {
|
|
1359
|
+
const results = [];
|
|
1360
|
+
|
|
1361
|
+
// 1. 本地代理端口检测
|
|
1362
|
+
results.push({
|
|
1363
|
+
item: 'local_proxy',
|
|
1364
|
+
name: `本地反向代理端口 (${this.proxyPort})`,
|
|
1365
|
+
status: this.proxy ? 'pass' : 'fail',
|
|
1366
|
+
detail: this.proxy ? `正常运行中 (代理目标端口: ${this.dshPort})` : '代理未启动',
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// 2. 局域网网卡检测
|
|
1370
|
+
const lanIp = selectLanIPv4();
|
|
1371
|
+
results.push({
|
|
1372
|
+
item: 'lan_interface',
|
|
1373
|
+
name: '局域网 IP 分配与可用性',
|
|
1374
|
+
status: lanIp ? 'pass' : 'warn',
|
|
1375
|
+
detail: lanIp ? `检测到有效局域网 IPv4: ${lanIp}` : '未检测到活跃局域网 IPv4 地址 (可能未连接 Wi-Fi/以太网)',
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// 3. Cloudflare 边缘连通性测试
|
|
1379
|
+
const cfStart = Date.now();
|
|
1380
|
+
try {
|
|
1381
|
+
await new Promise((resolve, reject) => {
|
|
1382
|
+
const req = httpsGet('https://1.1.1.1', { timeout: 3500 }, (res) => {
|
|
1383
|
+
res.resume();
|
|
1384
|
+
resolve();
|
|
1385
|
+
});
|
|
1386
|
+
req.on('error', reject);
|
|
1387
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
|
|
1388
|
+
});
|
|
1389
|
+
const cfLatency = Date.now() - cfStart;
|
|
1390
|
+
results.push({
|
|
1391
|
+
item: 'cloudflare_edge',
|
|
1392
|
+
name: 'Cloudflare Anycast 边缘网络',
|
|
1393
|
+
status: 'pass',
|
|
1394
|
+
latencyMs: cfLatency,
|
|
1395
|
+
detail: `连接畅通 (延迟 ${cfLatency}ms)`,
|
|
1396
|
+
});
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
results.push({
|
|
1399
|
+
item: 'cloudflare_edge',
|
|
1400
|
+
name: 'Cloudflare Anycast 边缘网络',
|
|
1401
|
+
status: 'warn',
|
|
1402
|
+
detail: `连接异常: ${err.message} (临时公网隧道可能受阻)`,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// 4. 国内 npm 高速镜像源 (npmmirror)
|
|
1407
|
+
const npmStart = Date.now();
|
|
1408
|
+
try {
|
|
1409
|
+
await new Promise((resolve, reject) => {
|
|
1410
|
+
const req = httpsGet('https://registry.npmmirror.com', { timeout: 3500 }, (res) => {
|
|
1411
|
+
res.resume();
|
|
1412
|
+
resolve();
|
|
1413
|
+
});
|
|
1414
|
+
req.on('error', reject);
|
|
1415
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
|
|
1416
|
+
});
|
|
1417
|
+
const npmLatency = Date.now() - npmStart;
|
|
1418
|
+
results.push({
|
|
1419
|
+
item: 'npmmirror',
|
|
1420
|
+
name: '国内 npm 高速镜像源 (npmmirror)',
|
|
1421
|
+
status: 'pass',
|
|
1422
|
+
latencyMs: npmLatency,
|
|
1423
|
+
detail: `连接畅通 (延迟 ${npmLatency}ms)`,
|
|
1424
|
+
});
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
results.push({
|
|
1427
|
+
item: 'npmmirror',
|
|
1428
|
+
name: '国内 npm 高速镜像源 (npmmirror)',
|
|
1429
|
+
status: 'warn',
|
|
1430
|
+
detail: `连接超时或异常: ${err.message}`,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 5. 自建隧道部署服务器连通性检测
|
|
1435
|
+
const customServerUrl = this.customTunnelConfig?.serverUrl?.trim();
|
|
1436
|
+
if (customServerUrl) {
|
|
1437
|
+
const isRunning = Boolean(this.customTunnelClient?.running);
|
|
1438
|
+
const ctStart = Date.now();
|
|
1439
|
+
try {
|
|
1440
|
+
const parsedUrl = new URL(customServerUrl);
|
|
1441
|
+
const isSecure = parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'wss:';
|
|
1442
|
+
const getter = isSecure ? httpsGet : httpGet;
|
|
1443
|
+
const probeUrl = new URL(customServerUrl);
|
|
1444
|
+
probeUrl.protocol = isSecure ? 'https:' : 'http:';
|
|
1445
|
+
|
|
1446
|
+
await new Promise((resolve, reject) => {
|
|
1447
|
+
const req = getter(probeUrl.toString(), { timeout: 4000 }, (res) => {
|
|
1448
|
+
res.resume();
|
|
1449
|
+
resolve();
|
|
1450
|
+
});
|
|
1451
|
+
req.on('error', reject);
|
|
1452
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (4.0s)')); });
|
|
1453
|
+
});
|
|
1454
|
+
const ctLatency = Date.now() - ctStart;
|
|
1455
|
+
results.push({
|
|
1456
|
+
item: 'custom_tunnel_server',
|
|
1457
|
+
name: `自建隧道部署服务器 (${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ''})`,
|
|
1458
|
+
status: 'pass',
|
|
1459
|
+
latencyMs: ctLatency,
|
|
1460
|
+
detail: `服务器连通良好 (延迟 ${ctLatency}ms · 状态: ${isRunning ? '客户端在线运行中' : '待连接/就绪'})`,
|
|
1461
|
+
});
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
if (isRunning) {
|
|
1464
|
+
results.push({
|
|
1465
|
+
item: 'custom_tunnel_server',
|
|
1466
|
+
name: `自建隧道部署服务器 (${customServerUrl})`,
|
|
1467
|
+
status: 'pass',
|
|
1468
|
+
detail: '客户端在线运行中 (WebSocket 通道已建立)',
|
|
1469
|
+
});
|
|
1470
|
+
} else {
|
|
1471
|
+
results.push({
|
|
1472
|
+
item: 'custom_tunnel_server',
|
|
1473
|
+
name: `自建隧道部署服务器 (${customServerUrl})`,
|
|
1474
|
+
status: 'warn',
|
|
1475
|
+
detail: `无法连通自建服务器: ${err.message}`,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
} else {
|
|
1480
|
+
results.push({
|
|
1481
|
+
item: 'custom_tunnel_server',
|
|
1482
|
+
name: '自建隧道部署服务器',
|
|
1483
|
+
status: 'pass',
|
|
1484
|
+
detail: '未配置自建服务器(若已部署自建隧道可在「公网隧道」中配置)',
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const allPassed = results.every(r => r.status === 'pass');
|
|
1489
|
+
return {
|
|
1490
|
+
ok: true,
|
|
1491
|
+
timestamp: new Date().toISOString(),
|
|
1492
|
+
overall: allPassed ? 'healthy' : 'warning',
|
|
1493
|
+
results,
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async dispose() {
|
|
1498
|
+
this.stopCustomTunnel();
|
|
1499
|
+
this.stopCloudflared();
|
|
1500
|
+
if (this.proxy) {
|
|
1501
|
+
await this.proxy.stop();
|
|
1502
|
+
this.proxy = null;
|
|
1503
|
+
}
|
|
1504
|
+
if (this.gateway) {
|
|
1505
|
+
await this.gateway.stop();
|
|
1506
|
+
this.gateway = null;
|
|
1507
|
+
}
|
|
1508
|
+
if (this.gatewayAuth) {
|
|
1509
|
+
try { this.gatewayAuth.dispose(); } catch {}
|
|
1510
|
+
this.gatewayAuth = null;
|
|
1511
|
+
}
|
|
1512
|
+
this.qrCache.clear();
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* 插件入口
|
|
1518
|
+
*/
|
|
1519
|
+
function apply(ctx, config = {}) {
|
|
1520
|
+
const logger = ctx.logger(name);
|
|
1521
|
+
const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
|
|
1522
|
+
|
|
1523
|
+
if (!dshPort) {
|
|
1524
|
+
logger.error('webServer port unavailable');
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const proxyPort = config.port ?? 3082;
|
|
1529
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1530
|
+
const configFile = join(dshHome, 'dsh-bridge', 'config.json');
|
|
1531
|
+
const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
|
|
1532
|
+
|
|
1533
|
+
// 配置写入互斥锁队列,杜绝多平台并发写入造成文件覆盖损坏
|
|
1534
|
+
let configWriteQueue = Promise.resolve();
|
|
1535
|
+
|
|
1536
|
+
// 从 JSON 文件读取持久化配置
|
|
1537
|
+
async function loadConfig() {
|
|
1538
|
+
try {
|
|
1539
|
+
const raw = await readFile(configFile, 'utf8');
|
|
1540
|
+
return JSON.parse(raw);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return {};
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// 持久化配置到 JSON 文件(排队原子写入)
|
|
1547
|
+
async function saveConfig(data) {
|
|
1548
|
+
configWriteQueue = configWriteQueue.then(async () => {
|
|
1549
|
+
await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
|
|
1550
|
+
await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
|
|
1551
|
+
}).catch((err) => {
|
|
1552
|
+
logger.error('saveConfig failed: %s', err.message);
|
|
1553
|
+
});
|
|
1554
|
+
return configWriteQueue;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// 访问安全认证管理器
|
|
1558
|
+
const authManager = new AuthManager({
|
|
1559
|
+
config: config.auth ?? {},
|
|
1560
|
+
logger,
|
|
1561
|
+
onPersist: async (patch) => {
|
|
1562
|
+
const stored = await loadConfig();
|
|
1563
|
+
stored.auth = { ...(stored.auth ?? {}), ...patch };
|
|
1564
|
+
await saveConfig(stored);
|
|
1565
|
+
},
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
|
|
1569
|
+
async function checkEmergencyReset() {
|
|
1570
|
+
try {
|
|
1571
|
+
await unlink(emergencyResetFile);
|
|
1572
|
+
authManager.enabled = false;
|
|
1573
|
+
authManager.passwordHash = '';
|
|
1574
|
+
authManager.passwordSalt = '';
|
|
1575
|
+
authManager.adminPasswordHash = '';
|
|
1576
|
+
authManager.adminPasswordSalt = '';
|
|
1577
|
+
authManager.adminPolicy = 'password_unlock';
|
|
1578
|
+
authManager.mode = 'token_and_password';
|
|
1579
|
+
authManager.sessions.clear();
|
|
1580
|
+
authManager.adminSessions.clear();
|
|
1581
|
+
const stored = await loadConfig();
|
|
1582
|
+
delete stored.auth;
|
|
1583
|
+
await saveConfig(stored);
|
|
1584
|
+
logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
|
|
1585
|
+
} catch {}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// 启动时读取已保存的 auth 配置并执行保命标记检查
|
|
1589
|
+
checkEmergencyReset().then(() => loadConfig()).then((stored) => {
|
|
1590
|
+
if (stored?.auth) {
|
|
1591
|
+
if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
|
|
1592
|
+
if (stored.auth.mode) authManager.mode = stored.auth.mode;
|
|
1593
|
+
if (stored.auth.scope) authManager.scope = stored.auth.scope;
|
|
1594
|
+
if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
|
|
1595
|
+
if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
|
|
1596
|
+
if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
|
|
1597
|
+
if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
|
|
1598
|
+
if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
|
|
1599
|
+
if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
|
|
1600
|
+
if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
|
|
1601
|
+
logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
|
|
1602
|
+
}
|
|
1603
|
+
}).catch(() => {});
|
|
1604
|
+
|
|
1605
|
+
const service = new BridgeService({
|
|
1606
|
+
dshPort,
|
|
1607
|
+
proxyPort,
|
|
1608
|
+
home: config.home,
|
|
1609
|
+
customTunnelConfig: config.customTunnel ?? null,
|
|
1610
|
+
cloudflaredConfig: config.cloudflared ?? null,
|
|
1611
|
+
lanConfig: config.lan ?? null,
|
|
1612
|
+
gatewayConfig: config.gateway ?? null,
|
|
1613
|
+
authManager,
|
|
1614
|
+
onPersist: async (patch) => {
|
|
1615
|
+
const stored = await loadConfig();
|
|
1616
|
+
Object.assign(stored, patch);
|
|
1617
|
+
await saveConfig(stored);
|
|
1618
|
+
},
|
|
1619
|
+
logger,
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
// 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
|
|
1623
|
+
loadConfig().then(async (stored) => {
|
|
1624
|
+
if (stored?.lan?.selectedIp) {
|
|
1625
|
+
service.selectedLanIp = stored.lan.selectedIp;
|
|
1626
|
+
logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
|
|
1627
|
+
}
|
|
1628
|
+
if (stored?.gateway) {
|
|
1629
|
+
service.gatewayConfig = stored.gateway;
|
|
1630
|
+
logger.info('dsh-bridge: loaded saved gateway config (port=%s, autoStart=%s)', service.gatewayConfig.port ?? 7443, Boolean(service.gatewayConfig.autoStart));
|
|
1631
|
+
if (service.gatewayConfig.autoStart) {
|
|
1632
|
+
logger.info('dsh-bridge: auto-starting public direct gateway...');
|
|
1633
|
+
service.startGateway().catch((err) => {
|
|
1634
|
+
logger.error('dsh-bridge: gateway auto-start failed: %s', err?.message ?? err);
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
if (stored?.cloudflared) {
|
|
1639
|
+
service.cloudflaredConfig = stored.cloudflared;
|
|
1640
|
+
logger.info('dsh-bridge: loaded saved cloudflared config (autoStart=%s, tokenConfigured=%s)', Boolean(service.cloudflaredConfig.autoStart), Boolean(service.cloudflaredConfig.token));
|
|
1641
|
+
if (service.cloudflaredConfig.autoStart) {
|
|
1642
|
+
logger.info('dsh-bridge: auto-starting cloudflared tunnel...');
|
|
1643
|
+
service.startCloudflared({ autoStart: true }).catch((err) => {
|
|
1644
|
+
logger.error('dsh-bridge: cloudflared auto-start failed: %s', err?.message ?? err);
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (stored?.customTunnel) {
|
|
1650
|
+
service.customTunnelConfig = stored.customTunnel;
|
|
1651
|
+
logger.info('dsh-bridge: loaded saved custom tunnel config (autoStart=%s)', Boolean(service.customTunnelConfig.autoStart));
|
|
1652
|
+
if (service.customTunnelConfig.autoStart && service.customTunnelConfig.serverUrl) {
|
|
1653
|
+
logger.info('dsh-bridge: auto-starting custom tunnel...');
|
|
1654
|
+
service.startCustomTunnel({ autoStart: true }).catch((err) => {
|
|
1655
|
+
logger.error('dsh-bridge: custom tunnel auto-start failed: %s', err?.message ?? err);
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}).catch(() => {});
|
|
1660
|
+
|
|
1661
|
+
// 平台管理器:注册/协调所有 IM 平台适配器
|
|
1662
|
+
const platformManager = new PlatformManager({ logger });
|
|
1663
|
+
|
|
1664
|
+
// 微信 Bot(ClawBot/iLink)—— 作为 Platform 子类注册进平台管理器
|
|
1665
|
+
const wechat = new WechatService({
|
|
1666
|
+
ctx,
|
|
1667
|
+
logger,
|
|
1668
|
+
config: config.wechat ?? {},
|
|
1669
|
+
onPersist: async (patch) => {
|
|
1670
|
+
const stored = await loadConfig();
|
|
1671
|
+
stored.wechat = { ...(stored.wechat ?? {}), ...patch };
|
|
1672
|
+
await saveConfig(stored);
|
|
1673
|
+
},
|
|
1674
|
+
});
|
|
1675
|
+
platformManager.register(wechat);
|
|
1676
|
+
|
|
1677
|
+
// QQ Bot(OpenAPI v2)—— 作为 Platform 子类注册进平台管理器
|
|
1678
|
+
const qq = new QqService({
|
|
1679
|
+
ctx,
|
|
1680
|
+
logger,
|
|
1681
|
+
config: config.qq ?? {},
|
|
1682
|
+
onPersist: async (patch) => {
|
|
1683
|
+
const stored = await loadConfig();
|
|
1684
|
+
stored.qq = { ...(stored.qq ?? {}), ...patch };
|
|
1685
|
+
await saveConfig(stored);
|
|
1686
|
+
},
|
|
1687
|
+
});
|
|
1688
|
+
platformManager.register(qq);
|
|
1689
|
+
|
|
1690
|
+
// 飞书 Bot(官方 OpenAPI / WebSocket 长连接)—— 作为 Platform 子类注册进平台管理器
|
|
1691
|
+
const feishu = new FeishuService({
|
|
1692
|
+
ctx,
|
|
1693
|
+
logger,
|
|
1694
|
+
config: config.feishu ?? {},
|
|
1695
|
+
onPersist: async (patch) => {
|
|
1696
|
+
const stored = await loadConfig();
|
|
1697
|
+
stored.feishu = { ...(stored.feishu ?? {}), ...patch };
|
|
1698
|
+
await saveConfig(stored);
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1701
|
+
platformManager.register(feishu);
|
|
1702
|
+
|
|
1703
|
+
// Telegram Bot(官方 Long Polling + 代理支持)—— 作为 Platform 子类注册进平台管理器
|
|
1704
|
+
const telegram = new TelegramService({
|
|
1705
|
+
ctx,
|
|
1706
|
+
logger,
|
|
1707
|
+
config: config.telegram ?? {},
|
|
1708
|
+
onPersist: async (patch) => {
|
|
1709
|
+
const stored = await loadConfig();
|
|
1710
|
+
stored.telegram = { ...(stored.telegram ?? {}), ...patch };
|
|
1711
|
+
await saveConfig(stored);
|
|
1712
|
+
},
|
|
1713
|
+
});
|
|
1714
|
+
platformManager.register(telegram);
|
|
1715
|
+
|
|
1716
|
+
// 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
|
|
1717
|
+
loadConfig().then(async (stored) => {
|
|
1718
|
+
if (stored?.wechat) {
|
|
1719
|
+
const cfg = stored.wechat;
|
|
1720
|
+
wechat.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1721
|
+
if (cfg.digestIntervalSec != null) wechat.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
|
|
1722
|
+
if (cfg.approvalTimeoutSec != null) wechat.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
|
|
1723
|
+
if (cfg.maxMessageChars != null) {
|
|
1724
|
+
const val = Number(cfg.maxMessageChars);
|
|
1725
|
+
wechat.node.config.maxMessageChars = (val >= 200) ? val : 2000;
|
|
1726
|
+
}
|
|
1727
|
+
if (cfg.sendChunkDelayMs != null) wechat.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
|
|
1728
|
+
|
|
1729
|
+
wechat.node._restoringConfig = (async () => {
|
|
1730
|
+
try {
|
|
1731
|
+
if (cfg.activeSessionId) {
|
|
1732
|
+
wechat.node.activeSessionId = cfg.activeSessionId;
|
|
1733
|
+
logger.info('dsh-bridge: restored wechat active session: %s', cfg.activeSessionId);
|
|
1734
|
+
} else {
|
|
1735
|
+
await wechat.node._pickDefaultSession().catch(() => {});
|
|
1736
|
+
}
|
|
1737
|
+
} finally {
|
|
1738
|
+
wechat.node._configRestored = true;
|
|
1739
|
+
}
|
|
1740
|
+
})();
|
|
1741
|
+
|
|
1742
|
+
await wechat.node._restoringConfig;
|
|
1743
|
+
|
|
1744
|
+
if (cfg.token && cfg.accountId) {
|
|
1745
|
+
wechat.gateway.setCredentials({
|
|
1746
|
+
token: cfg.token,
|
|
1747
|
+
accountId: cfg.accountId,
|
|
1748
|
+
baseUrl: cfg.baseUrl,
|
|
1749
|
+
});
|
|
1750
|
+
logger.info('dsh-bridge: loaded saved wechat bot config, starting gateway');
|
|
1751
|
+
await wechat.start().catch((err) => {
|
|
1752
|
+
logger.error('dsh-bridge: wechat auto-start failed: %s', err?.message ?? err);
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}).catch(() => {});
|
|
1757
|
+
|
|
1758
|
+
// 启动时读取已保存的 QQ Bot 配置(凭证 + 白名单 + 活动会话)
|
|
1759
|
+
loadConfig().then(async (stored) => {
|
|
1760
|
+
if (stored?.qq) {
|
|
1761
|
+
const cfg = stored.qq;
|
|
1762
|
+
qq.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1763
|
+
|
|
1764
|
+
qq.node._restoringConfig = (async () => {
|
|
1765
|
+
try {
|
|
1766
|
+
if (cfg.activeSessionId) {
|
|
1767
|
+
qq.node.activeSessionId = cfg.activeSessionId;
|
|
1768
|
+
logger.info('dsh-bridge: restored qq active session: %s', cfg.activeSessionId);
|
|
1769
|
+
} else {
|
|
1770
|
+
await qq.node._pickDefaultSession().catch(() => {});
|
|
1771
|
+
}
|
|
1772
|
+
} finally {
|
|
1773
|
+
qq.node._configRestored = true;
|
|
1774
|
+
}
|
|
1775
|
+
})();
|
|
1776
|
+
|
|
1777
|
+
await qq.node._restoringConfig;
|
|
1778
|
+
|
|
1779
|
+
if (cfg.appId && cfg.clientSecret) {
|
|
1780
|
+
qq.gateway.setCredentials({
|
|
1781
|
+
appId: cfg.appId,
|
|
1782
|
+
clientSecret: cfg.clientSecret,
|
|
1783
|
+
accessToken: cfg.accessToken,
|
|
1784
|
+
accessTokenExpiresAt: cfg.accessTokenExpiresAt,
|
|
1785
|
+
gatewayUrl: cfg.gatewayUrl,
|
|
1786
|
+
accountId: cfg.accountId,
|
|
1787
|
+
});
|
|
1788
|
+
logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
|
|
1789
|
+
await qq.start().catch((err) => {
|
|
1790
|
+
logger.error('dsh-bridge: qq auto-start failed: %s', err?.message ?? err);
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}).catch(() => {});
|
|
1795
|
+
|
|
1796
|
+
// 启动时读取已保存的飞书 Bot 配置(凭证 + 白名单 + 活动会话)
|
|
1797
|
+
loadConfig().then(async (stored) => {
|
|
1798
|
+
if (stored?.feishu) {
|
|
1799
|
+
const cfg = stored.feishu;
|
|
1800
|
+
feishu.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1801
|
+
|
|
1802
|
+
feishu.node._restoringConfig = (async () => {
|
|
1803
|
+
try {
|
|
1804
|
+
if (cfg.activeSessionId) {
|
|
1805
|
+
feishu.node.activeSessionId = cfg.activeSessionId;
|
|
1806
|
+
logger.info('dsh-bridge: restored feishu active session: %s', cfg.activeSessionId);
|
|
1807
|
+
} else {
|
|
1808
|
+
await feishu.node._pickDefaultSession().catch(() => {});
|
|
1809
|
+
}
|
|
1810
|
+
} finally {
|
|
1811
|
+
feishu.node._configRestored = true;
|
|
1812
|
+
}
|
|
1813
|
+
})();
|
|
1814
|
+
|
|
1815
|
+
await feishu.node._restoringConfig;
|
|
1816
|
+
|
|
1817
|
+
if (cfg.appId && cfg.appSecret) {
|
|
1818
|
+
feishu.gateway.updateConfig({
|
|
1819
|
+
appId: cfg.appId,
|
|
1820
|
+
appSecret: cfg.appSecret,
|
|
1821
|
+
domain: cfg.domain || 'feishu',
|
|
1822
|
+
});
|
|
1823
|
+
logger.info('dsh-bridge: loaded saved feishu bot config, starting gateway');
|
|
1824
|
+
await feishu.start().catch((err) => {
|
|
1825
|
+
logger.error('dsh-bridge: feishu auto-start failed: %s', err?.message ?? err);
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}).catch(() => {});
|
|
1830
|
+
|
|
1831
|
+
// 启动时读取已保存的 Telegram Bot 配置(凭证 + 代理 + 白名单 + 活动会话)
|
|
1832
|
+
loadConfig().then(async (stored) => {
|
|
1833
|
+
if (stored?.telegram) {
|
|
1834
|
+
const cfg = stored.telegram;
|
|
1835
|
+
telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1836
|
+
if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
|
|
1837
|
+
if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
|
|
1838
|
+
if (cfg.maxMessageChars != null) {
|
|
1839
|
+
const val = Number(cfg.maxMessageChars);
|
|
1840
|
+
telegram.node.config.maxMessageChars = (val >= 200) ? val : 4096;
|
|
1841
|
+
}
|
|
1842
|
+
if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
|
|
1843
|
+
|
|
1844
|
+
telegram.node._restoringConfig = (async () => {
|
|
1845
|
+
try {
|
|
1846
|
+
if (cfg.activeSessionId) {
|
|
1847
|
+
telegram.node.activeSessionId = cfg.activeSessionId;
|
|
1848
|
+
logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
|
|
1849
|
+
} else {
|
|
1850
|
+
await telegram.node._pickDefaultSession().catch(() => {});
|
|
1851
|
+
}
|
|
1852
|
+
} finally {
|
|
1853
|
+
telegram.node._configRestored = true;
|
|
1854
|
+
}
|
|
1855
|
+
})();
|
|
1856
|
+
|
|
1857
|
+
await telegram.node._restoringConfig;
|
|
1858
|
+
|
|
1859
|
+
if (cfg.botToken) {
|
|
1860
|
+
telegram.gateway.setCredentials({
|
|
1861
|
+
botToken: cfg.botToken,
|
|
1862
|
+
proxy: cfg.proxy || '',
|
|
1863
|
+
});
|
|
1864
|
+
logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
|
|
1865
|
+
await telegram.start().catch((err) => {
|
|
1866
|
+
logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}).catch(() => {});
|
|
1871
|
+
|
|
1872
|
+
const disposeRpc = installBridgeRpc(ctx, {
|
|
1873
|
+
service,
|
|
1874
|
+
authManager,
|
|
1875
|
+
wechat,
|
|
1876
|
+
qq,
|
|
1877
|
+
feishu,
|
|
1878
|
+
telegram,
|
|
1879
|
+
platformManager,
|
|
1880
|
+
logger,
|
|
1881
|
+
saveCustomTunnelConfig: async (serverUrl, accessToken) => {
|
|
1882
|
+
const stored = await loadConfig();
|
|
1883
|
+
stored.customTunnel = { serverUrl, accessToken };
|
|
1884
|
+
await saveConfig(stored);
|
|
1885
|
+
service.customTunnelConfig = { serverUrl, accessToken };
|
|
1886
|
+
},
|
|
1887
|
+
exportBackup: async () => {
|
|
1888
|
+
const stored = await loadConfig();
|
|
1889
|
+
return {
|
|
1890
|
+
version: VERSION,
|
|
1891
|
+
exportedAt: new Date().toISOString(),
|
|
1892
|
+
config: stored,
|
|
1893
|
+
};
|
|
1894
|
+
},
|
|
1895
|
+
importBackup: async (backup) => {
|
|
1896
|
+
if (!backup || typeof backup !== 'object' || !backup.config || typeof backup.config !== 'object') {
|
|
1897
|
+
throw new Error('无效的备份数据结构:缺少 config 节点');
|
|
1898
|
+
}
|
|
1899
|
+
const incoming = backup.config;
|
|
1900
|
+
await saveConfig(incoming);
|
|
1901
|
+
|
|
1902
|
+
// 重新载入 Auth
|
|
1903
|
+
if (incoming.auth) {
|
|
1904
|
+
if (incoming.auth.enabled != null) authManager.enabled = Boolean(incoming.auth.enabled);
|
|
1905
|
+
if (incoming.auth.mode) authManager.mode = incoming.auth.mode;
|
|
1906
|
+
if (incoming.auth.scope) authManager.scope = incoming.auth.scope;
|
|
1907
|
+
if (incoming.auth.adminPolicy) authManager.adminPolicy = incoming.auth.adminPolicy;
|
|
1908
|
+
if (incoming.auth.passwordHash) authManager.passwordHash = incoming.auth.passwordHash;
|
|
1909
|
+
if (incoming.auth.passwordSalt) authManager.passwordSalt = incoming.auth.passwordSalt;
|
|
1910
|
+
if (incoming.auth.adminPasswordHash) authManager.adminPasswordHash = incoming.auth.adminPasswordHash;
|
|
1911
|
+
if (incoming.auth.adminPasswordSalt) authManager.adminPasswordSalt = incoming.auth.adminPasswordSalt;
|
|
1912
|
+
if (incoming.auth.secretToken) authManager.secretToken = incoming.auth.secretToken;
|
|
1913
|
+
}
|
|
1914
|
+
// 重新载入 Tunnels
|
|
1915
|
+
if (incoming.cloudflared) {
|
|
1916
|
+
service.cloudflaredConfig = incoming.cloudflared;
|
|
1917
|
+
}
|
|
1918
|
+
if (incoming.customTunnel) {
|
|
1919
|
+
service.customTunnelConfig = incoming.customTunnel;
|
|
1920
|
+
}
|
|
1921
|
+
// 重新载入各 IM 平台白名单与配置
|
|
1922
|
+
if (incoming.wechat) wechat.node.config.allowFrom = incoming.wechat.allowFrom ?? [];
|
|
1923
|
+
if (incoming.qq) {
|
|
1924
|
+
qq.node.config.allowFrom = incoming.qq.allowFrom ?? [];
|
|
1925
|
+
if (incoming.qq.appId && incoming.qq.clientSecret) {
|
|
1926
|
+
qq.gateway.setCredentials({ appId: incoming.qq.appId, clientSecret: incoming.qq.clientSecret });
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (incoming.feishu) {
|
|
1930
|
+
feishu.node.config.allowFrom = incoming.feishu.allowFrom ?? [];
|
|
1931
|
+
if (incoming.feishu.appId && incoming.feishu.appSecret) {
|
|
1932
|
+
feishu.gateway.setCredentials({ appId: incoming.feishu.appId, appSecret: incoming.feishu.appSecret });
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
if (incoming.telegram) {
|
|
1936
|
+
telegram.node.config.allowFrom = incoming.telegram.allowFrom ?? [];
|
|
1937
|
+
if (incoming.telegram.botToken) {
|
|
1938
|
+
telegram.gateway.setCredentials({ botToken: incoming.telegram.botToken, proxy: incoming.telegram.proxy || '' });
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
return { ok: true, message: '配置已成功导入并刷新生效!' };
|
|
1943
|
+
},
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// 代理随插件自动启动
|
|
1947
|
+
void service.startProxy().catch((err) => {
|
|
1948
|
+
logger.error('dsh-bridge: proxy start failed: %s', err?.message ?? err);
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
ctx.effect(() => async () => {
|
|
1952
|
+
try { disposeRpc(); } catch {}
|
|
1953
|
+
await wechat.destroy();
|
|
1954
|
+
await qq.destroy();
|
|
1955
|
+
await feishu.destroy();
|
|
1956
|
+
await telegram.destroy();
|
|
1957
|
+
platformManager.dispose();
|
|
1958
|
+
authManager.dispose();
|
|
1959
|
+
await service.dispose();
|
|
1960
|
+
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4, listAllLanIPv4 };
|