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