@wenbin_wb/dsh-bridge 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +46 -9
- package/README.md +46 -9
- package/client/client.js +821 -28
- package/client/index.js +711 -35
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/wechat-bot-plan.md +294 -0
- package/lib/auth/login-template.js +378 -0
- package/lib/auth/manager.js +470 -0
- package/lib/bridge-rpc-constants.js +6 -0
- package/lib/bridge-rpc.js +126 -6
- package/lib/feishu/node.js +4 -4
- package/lib/index.js +258 -32
- package/lib/qq/node.js +9 -2
- package/lib/telegram/node.js +4 -4
- package/lib/tunnel-client.mjs +11 -2
- package/lib/wechat/media.js +8 -2
- package/lib/wechat/node.js +2 -2
- package/package.json +6 -9
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
15
|
-
import {
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
16
|
import QRCode from 'qrcode';
|
|
17
17
|
import { installBridgeRpc } from './bridge-rpc.js';
|
|
18
18
|
import { CustomTunnelClient } from './tunnel-client.mjs';
|
|
@@ -22,6 +22,8 @@ import { WechatService } from './wechat/index.js';
|
|
|
22
22
|
import { QqService } from './qq/index.js';
|
|
23
23
|
import { FeishuService } from './feishu/index.js';
|
|
24
24
|
import { TelegramService } from './telegram/index.js';
|
|
25
|
+
import { AuthManager } from './auth/manager.js';
|
|
26
|
+
import { renderLoginPage } from './auth/login-template.js';
|
|
25
27
|
|
|
26
28
|
const name = 'dsh-bridge';
|
|
27
29
|
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
@@ -127,14 +129,15 @@ function loopbackHeaders(headers, targetPort) {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
|
-
* HTTP + WebSocket
|
|
132
|
+
* HTTP + WebSocket 代理服务器(带安全认证守门)
|
|
131
133
|
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
132
|
-
*
|
|
134
|
+
* 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
|
|
133
135
|
*/
|
|
134
136
|
class ProxyServer {
|
|
135
|
-
constructor({ localPort, targetPort, logger }) {
|
|
137
|
+
constructor({ localPort, targetPort, authManager, logger }) {
|
|
136
138
|
this.localPort = localPort;
|
|
137
139
|
this.targetPort = targetPort;
|
|
140
|
+
this.authManager = authManager;
|
|
138
141
|
this.logger = logger;
|
|
139
142
|
this.server = null;
|
|
140
143
|
this.clientSockets = new Set();
|
|
@@ -145,6 +148,101 @@ class ProxyServer {
|
|
|
145
148
|
if (this.server) return;
|
|
146
149
|
|
|
147
150
|
this.server = createServer((req, res) => {
|
|
151
|
+
// 1. 处理登录 API: POST /__dsh_bridge__/login
|
|
152
|
+
if (req.url === '/__dsh_bridge__/login' && req.method === 'POST') {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
req.on('data', (c) => chunks.push(c));
|
|
155
|
+
req.on('end', () => {
|
|
156
|
+
try {
|
|
157
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
158
|
+
const clientIp = req.socket?.remoteAddress || '';
|
|
159
|
+
const verify = this.authManager?.verifyPassword(body.password, clientIp);
|
|
160
|
+
if (verify?.success) {
|
|
161
|
+
const sessionToken = this.authManager.createSession();
|
|
162
|
+
res.writeHead(200, {
|
|
163
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
164
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
165
|
+
});
|
|
166
|
+
res.end(JSON.stringify({ ok: true }));
|
|
167
|
+
} else {
|
|
168
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
169
|
+
res.end(JSON.stringify({ ok: false, error: verify?.error || '访问密码错误' }));
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
173
|
+
res.end(JSON.stringify({ ok: false, error: '无效请求' }));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. 处理登出 API: POST /__dsh_bridge__/logout
|
|
180
|
+
if (req.url === '/__dsh_bridge__/logout' && req.method === 'POST') {
|
|
181
|
+
res.writeHead(200, {
|
|
182
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
183
|
+
'Set-Cookie': 'dsh_bridge_auth=; Path=/; HttpOnly; Max-Age=0',
|
|
184
|
+
});
|
|
185
|
+
res.end(JSON.stringify({ ok: true }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status (严格脱敏,不暴露 secretToken)
|
|
190
|
+
if (req.url === '/__dsh_bridge__/auth-status' && req.method === 'GET') {
|
|
191
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
192
|
+
res.end(JSON.stringify(this.authManager?.getPublicStatus() ?? { enabled: false }));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 4. 核心鉴权拦截
|
|
197
|
+
const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
|
|
198
|
+
|
|
199
|
+
// 4.1 若从 URL Token 认证通过:下发 Cookie 并 302 重定向到干净 URL (去掉 ?auth=)
|
|
200
|
+
if (auth.fromToken) {
|
|
201
|
+
const sessionToken = this.authManager.createSession();
|
|
202
|
+
try {
|
|
203
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
204
|
+
urlObj.searchParams.delete('auth');
|
|
205
|
+
urlObj.searchParams.delete('token');
|
|
206
|
+
const cleanPath = (urlObj.pathname || '/') + (urlObj.search || '');
|
|
207
|
+
res.writeHead(302, {
|
|
208
|
+
'Location': cleanPath,
|
|
209
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
210
|
+
});
|
|
211
|
+
res.end();
|
|
212
|
+
return;
|
|
213
|
+
} catch {
|
|
214
|
+
res.writeHead(302, {
|
|
215
|
+
'Location': '/',
|
|
216
|
+
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
217
|
+
});
|
|
218
|
+
res.end();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 4.2 若未通过认证:根据请求类型渲染 DSH 登录页或返回 401 JSON
|
|
224
|
+
if (!auth.authenticated) {
|
|
225
|
+
const accept = String(req.headers['accept'] || '');
|
|
226
|
+
const isHtml = accept.includes('text/html') || (!req.url.startsWith('/api/') && !req.url.includes('.'));
|
|
227
|
+
if (isHtml) {
|
|
228
|
+
const clientIp = req.socket?.remoteAddress || '';
|
|
229
|
+
const isLocked = this.authManager?.isIpBlocked(clientIp);
|
|
230
|
+
const html = renderLoginPage({
|
|
231
|
+
hasPassword: this.authManager?.hasPassword,
|
|
232
|
+
locked: isLocked,
|
|
233
|
+
error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
|
|
234
|
+
});
|
|
235
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
236
|
+
res.end(html);
|
|
237
|
+
return;
|
|
238
|
+
} else {
|
|
239
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
240
|
+
res.end(JSON.stringify({ error: 'unauthorized', message: '需要访问认证,请先登录' }));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 5. 认证通过:正常执行反向代理转发
|
|
148
246
|
const headers = loopbackHeaders(req.headers, this.targetPort);
|
|
149
247
|
const proxyReq = httpRequest(
|
|
150
248
|
{ host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
|
|
@@ -185,8 +283,15 @@ class ProxyServer {
|
|
|
185
283
|
req.pipe(proxyReq);
|
|
186
284
|
});
|
|
187
285
|
|
|
188
|
-
// WebSocket upgrade
|
|
286
|
+
// WebSocket upgrade 鉴权与代理
|
|
189
287
|
this.server.on('upgrade', (req, socket, head) => {
|
|
288
|
+
const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
|
|
289
|
+
if (!auth.authenticated) {
|
|
290
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nUnauthorized\r\n');
|
|
291
|
+
socket.destroy();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
190
295
|
const headers = loopbackHeaders(req.headers, this.targetPort);
|
|
191
296
|
const proxyReq = httpRequest({
|
|
192
297
|
host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
|
|
@@ -259,11 +364,12 @@ class ProxyServer {
|
|
|
259
364
|
* Bridge Service
|
|
260
365
|
*/
|
|
261
366
|
class BridgeService {
|
|
262
|
-
constructor({ dshPort, proxyPort, home, customTunnelConfig, logger }) {
|
|
367
|
+
constructor({ dshPort, proxyPort, home, customTunnelConfig, authManager, logger }) {
|
|
263
368
|
this.dshPort = dshPort;
|
|
264
369
|
this.proxyPort = proxyPort;
|
|
265
370
|
this.home = home;
|
|
266
371
|
this.customTunnelConfig = customTunnelConfig ?? null;
|
|
372
|
+
this.authManager = authManager ?? null;
|
|
267
373
|
this.logger = logger;
|
|
268
374
|
|
|
269
375
|
this.qrCache = new QrCache();
|
|
@@ -282,6 +388,7 @@ class BridgeService {
|
|
|
282
388
|
this.proxy = new ProxyServer({
|
|
283
389
|
localPort: this.proxyPort,
|
|
284
390
|
targetPort: this.dshPort,
|
|
391
|
+
authManager: this.authManager,
|
|
285
392
|
logger: this.logger,
|
|
286
393
|
});
|
|
287
394
|
|
|
@@ -289,13 +396,40 @@ class BridgeService {
|
|
|
289
396
|
return this.proxy;
|
|
290
397
|
}
|
|
291
398
|
|
|
292
|
-
async getStatus() {
|
|
399
|
+
async getStatus({ adminAuthValid = false } = {}) {
|
|
293
400
|
const lanIp = selectLanIPv4();
|
|
294
|
-
const
|
|
401
|
+
const token = adminAuthValid ? this.authManager?.secretToken : null;
|
|
402
|
+
const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
|
|
403
|
+
|
|
404
|
+
const isLanProtected = isAuthEnabled && this.authManager?.scope !== 'public_only';
|
|
405
|
+
const isPublicProtected = isAuthEnabled && this.authManager?.scope !== 'lan_only';
|
|
406
|
+
|
|
407
|
+
const appendToken = (url, shouldAppend) => {
|
|
408
|
+
if (!url || !shouldAppend || !token) return url;
|
|
409
|
+
try {
|
|
410
|
+
const u = new URL(url);
|
|
411
|
+
u.searchParams.set('auth', token);
|
|
412
|
+
return u.toString();
|
|
413
|
+
} catch {
|
|
414
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
415
|
+
return `${url}${sep}auth=${encodeURIComponent(token)}`;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const baseLanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
|
|
420
|
+
const lanUrl = appendToken(baseLanUrl, isLanProtected);
|
|
421
|
+
|
|
422
|
+
const baseCloudflaredUrl = this.cloudflared?.url || null;
|
|
423
|
+
const cloudflaredUrl = appendToken(baseCloudflaredUrl, isPublicProtected);
|
|
424
|
+
|
|
425
|
+
const baseCustomUrl = this.customTunnel?.publicUrl || null;
|
|
426
|
+
const customUrl = appendToken(baseCustomUrl, isPublicProtected);
|
|
295
427
|
|
|
296
428
|
return {
|
|
297
429
|
version: VERSION,
|
|
298
430
|
|
|
431
|
+
auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
|
|
432
|
+
|
|
299
433
|
proxy: {
|
|
300
434
|
running: !!this.proxy,
|
|
301
435
|
port: this.proxyPort,
|
|
@@ -305,14 +439,16 @@ class BridgeService {
|
|
|
305
439
|
lan: {
|
|
306
440
|
ip: lanIp,
|
|
307
441
|
url: lanUrl,
|
|
442
|
+
rawUrl: baseLanUrl,
|
|
308
443
|
qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
|
|
309
444
|
},
|
|
310
445
|
|
|
311
446
|
cloudflared: {
|
|
312
447
|
running: !!this.cloudflared,
|
|
313
|
-
url:
|
|
314
|
-
|
|
315
|
-
|
|
448
|
+
url: cloudflaredUrl,
|
|
449
|
+
rawUrl: baseCloudflaredUrl,
|
|
450
|
+
qr: cloudflaredUrl
|
|
451
|
+
? await this.qrCache.get(cloudflaredUrl)
|
|
316
452
|
: null,
|
|
317
453
|
state: this.cloudflaredState,
|
|
318
454
|
},
|
|
@@ -321,9 +457,10 @@ class BridgeService {
|
|
|
321
457
|
configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
|
|
322
458
|
serverUrl: this.customTunnelConfig?.serverUrl ?? '',
|
|
323
459
|
running: !!this.customTunnel?.connected,
|
|
324
|
-
url:
|
|
325
|
-
|
|
326
|
-
|
|
460
|
+
url: customUrl,
|
|
461
|
+
rawUrl: baseCustomUrl,
|
|
462
|
+
qr: customUrl
|
|
463
|
+
? await this.qrCache.get(customUrl)
|
|
327
464
|
: null,
|
|
328
465
|
state: this.customTunnelState,
|
|
329
466
|
},
|
|
@@ -349,6 +486,7 @@ class BridgeService {
|
|
|
349
486
|
serverUrl,
|
|
350
487
|
accessToken,
|
|
351
488
|
localPort: this.proxyPort,
|
|
489
|
+
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
352
490
|
onStateChange: (state) => {
|
|
353
491
|
this.customTunnelState = state;
|
|
354
492
|
},
|
|
@@ -438,29 +576,57 @@ class BridgeService {
|
|
|
438
576
|
}
|
|
439
577
|
}
|
|
440
578
|
|
|
441
|
-
// 一键直接升级插件(执行 dsh
|
|
579
|
+
// 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
|
|
442
580
|
async upgradePlugin({ version } = {}) {
|
|
443
581
|
const targetVersion = version ? String(version).trim() : 'latest';
|
|
582
|
+
// 严格 SemVer 白名单正则校验
|
|
583
|
+
if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
|
|
584
|
+
return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
|
|
585
|
+
}
|
|
444
586
|
const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
587
|
+
const isWin = process.platform === 'win32';
|
|
588
|
+
|
|
589
|
+
const tasks = [
|
|
590
|
+
{ cmd: isWin ? 'dsh.cmd' : 'dsh', fallbackCmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
|
|
591
|
+
{ cmd: isWin ? 'npx.cmd' : 'npx', fallbackCmd: 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
|
|
592
|
+
{ cmd: isWin ? 'npm.cmd' : 'npm', fallbackCmd: 'npm', args: ['install', pkgSpec] },
|
|
449
593
|
];
|
|
450
594
|
|
|
451
595
|
let lastError = null;
|
|
452
|
-
let output = '';
|
|
453
596
|
|
|
454
|
-
for (const
|
|
597
|
+
for (const task of tasks) {
|
|
455
598
|
try {
|
|
456
599
|
const res = await new Promise((resolve, reject) => {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
600
|
+
const runExecutable = (executable) => {
|
|
601
|
+
const cp = spawn(executable, task.args, {
|
|
602
|
+
windowsHide: true,
|
|
603
|
+
shell: false,
|
|
604
|
+
timeout: 90000,
|
|
605
|
+
});
|
|
606
|
+
let stdout = '';
|
|
607
|
+
let stderr = '';
|
|
608
|
+
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
609
|
+
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
610
|
+
cp.on('error', (err) => {
|
|
611
|
+
if (executable !== task.fallbackCmd) {
|
|
612
|
+
runExecutable(task.fallbackCmd);
|
|
613
|
+
} else {
|
|
614
|
+
reject(err);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
cp.on('close', (code) => {
|
|
618
|
+
if (code === 0) {
|
|
619
|
+
resolve({ stdout, stderr });
|
|
620
|
+
} else {
|
|
621
|
+
reject(new Error(stderr || stdout || `进程退出码 ${code}`));
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
runExecutable(task.cmd);
|
|
461
626
|
});
|
|
462
|
-
|
|
463
|
-
|
|
627
|
+
|
|
628
|
+
const output = res.stdout || res.stderr || '升级成功';
|
|
629
|
+
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
464
630
|
} catch (err) {
|
|
465
631
|
lastError = err;
|
|
466
632
|
}
|
|
@@ -495,6 +661,10 @@ function apply(ctx, config = {}) {
|
|
|
495
661
|
const proxyPort = config.port ?? 3082;
|
|
496
662
|
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
497
663
|
const configFile = join(dshHome, 'dsh-bridge', 'config.json');
|
|
664
|
+
const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
|
|
665
|
+
|
|
666
|
+
// 配置写入互斥锁队列,杜绝多平台并发写入造成文件覆盖损坏
|
|
667
|
+
let configWriteQueue = Promise.resolve();
|
|
498
668
|
|
|
499
669
|
// 从 JSON 文件读取持久化配置
|
|
500
670
|
async function loadConfig() {
|
|
@@ -506,17 +676,71 @@ function apply(ctx, config = {}) {
|
|
|
506
676
|
}
|
|
507
677
|
}
|
|
508
678
|
|
|
509
|
-
// 持久化配置到 JSON
|
|
679
|
+
// 持久化配置到 JSON 文件(排队原子写入)
|
|
510
680
|
async function saveConfig(data) {
|
|
511
|
-
|
|
512
|
-
|
|
681
|
+
configWriteQueue = configWriteQueue.then(async () => {
|
|
682
|
+
await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
|
|
683
|
+
await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
|
|
684
|
+
}).catch((err) => {
|
|
685
|
+
logger.error('saveConfig failed: %s', err.message);
|
|
686
|
+
});
|
|
687
|
+
return configWriteQueue;
|
|
513
688
|
}
|
|
514
689
|
|
|
690
|
+
// 访问安全认证管理器
|
|
691
|
+
const authManager = new AuthManager({
|
|
692
|
+
config: config.auth ?? {},
|
|
693
|
+
logger,
|
|
694
|
+
onPersist: async (patch) => {
|
|
695
|
+
const stored = await loadConfig();
|
|
696
|
+
stored.auth = { ...(stored.auth ?? {}), ...patch };
|
|
697
|
+
await saveConfig(stored);
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
|
|
702
|
+
async function checkEmergencyReset() {
|
|
703
|
+
try {
|
|
704
|
+
await unlink(emergencyResetFile);
|
|
705
|
+
authManager.enabled = false;
|
|
706
|
+
authManager.passwordHash = '';
|
|
707
|
+
authManager.passwordSalt = '';
|
|
708
|
+
authManager.adminPasswordHash = '';
|
|
709
|
+
authManager.adminPasswordSalt = '';
|
|
710
|
+
authManager.adminPolicy = 'password_unlock';
|
|
711
|
+
authManager.mode = 'token_and_password';
|
|
712
|
+
authManager.sessions.clear();
|
|
713
|
+
authManager.adminSessions.clear();
|
|
714
|
+
const stored = await loadConfig();
|
|
715
|
+
delete stored.auth;
|
|
716
|
+
await saveConfig(stored);
|
|
717
|
+
logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
|
|
718
|
+
} catch {}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 启动时读取已保存的 auth 配置并执行保命标记检查
|
|
722
|
+
checkEmergencyReset().then(() => loadConfig()).then((stored) => {
|
|
723
|
+
if (stored?.auth) {
|
|
724
|
+
if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
|
|
725
|
+
if (stored.auth.mode) authManager.mode = stored.auth.mode;
|
|
726
|
+
if (stored.auth.scope) authManager.scope = stored.auth.scope;
|
|
727
|
+
if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
|
|
728
|
+
if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
|
|
729
|
+
if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
|
|
730
|
+
if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
|
|
731
|
+
if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
|
|
732
|
+
if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
|
|
733
|
+
if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
|
|
734
|
+
logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
|
|
735
|
+
}
|
|
736
|
+
}).catch(() => {});
|
|
737
|
+
|
|
515
738
|
const service = new BridgeService({
|
|
516
739
|
dshPort,
|
|
517
740
|
proxyPort,
|
|
518
741
|
home: config.home,
|
|
519
742
|
customTunnelConfig: config.customTunnel ?? null,
|
|
743
|
+
authManager,
|
|
520
744
|
logger,
|
|
521
745
|
});
|
|
522
746
|
|
|
@@ -735,6 +959,7 @@ function apply(ctx, config = {}) {
|
|
|
735
959
|
|
|
736
960
|
const disposeRpc = installBridgeRpc(ctx, {
|
|
737
961
|
service,
|
|
962
|
+
authManager,
|
|
738
963
|
wechat,
|
|
739
964
|
qq,
|
|
740
965
|
feishu,
|
|
@@ -761,8 +986,9 @@ function apply(ctx, config = {}) {
|
|
|
761
986
|
await feishu.destroy();
|
|
762
987
|
await telegram.destroy();
|
|
763
988
|
platformManager.dispose();
|
|
989
|
+
authManager.dispose();
|
|
764
990
|
await service.dispose();
|
|
765
|
-
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy and tunnels');
|
|
991
|
+
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
|
|
766
992
|
}
|
|
767
993
|
|
|
768
|
-
export { name, inject, apply };
|
|
994
|
+
export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4 };
|
package/lib/qq/node.js
CHANGED
|
@@ -147,8 +147,8 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
147
147
|
this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
|
|
148
148
|
|
|
149
149
|
// 订阅网关入站事件
|
|
150
|
-
this.ctx.on('qq/message', (event) => this._handleInbound(event))
|
|
151
|
-
this.ctx.on('qq/interaction', (event) => this._handleInteraction(event))
|
|
150
|
+
this.disposers.push(this.ctx.on('qq/message', (event) => this._handleInbound(event)))
|
|
151
|
+
this.disposers.push(this.ctx.on('qq/interaction', (event) => this._handleInteraction(event)))
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
// ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
|
|
@@ -350,6 +350,13 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
350
350
|
})
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/** 发送多媒体附件,正确传递 c2c 或 group 范围 */
|
|
354
|
+
async sendMediaFile(filePath, opts = {}) {
|
|
355
|
+
const peerInfo = this._currentPeer()
|
|
356
|
+
if (!peerInfo) return
|
|
357
|
+
return this.gateway.sendMediaFile(peerInfo.peerId, filePath, { scope: peerInfo.scope, ...opts })
|
|
358
|
+
}
|
|
359
|
+
|
|
353
360
|
// ---- 入站 ----
|
|
354
361
|
|
|
355
362
|
async _handleInbound(event) {
|
package/lib/telegram/node.js
CHANGED
|
@@ -68,13 +68,13 @@ export class TelegramConversationNode extends ConversationBridge {
|
|
|
68
68
|
this._inTurn = false
|
|
69
69
|
|
|
70
70
|
// 订阅网关入站消息事件
|
|
71
|
-
this.ctx.on('telegram/message', (event) => this._handleInbound(event))
|
|
71
|
+
this.disposers.push(this.ctx.on('telegram/message', (event) => this._handleInbound(event)))
|
|
72
72
|
|
|
73
73
|
// 订阅 Inline 按钮点击交互事件(审批确认)
|
|
74
|
-
this.ctx.on('telegram/action', (event) => this._handleAction(event))
|
|
74
|
+
this.disposers.push(this.ctx.on('telegram/action', (event) => this._handleAction(event)))
|
|
75
75
|
|
|
76
76
|
// 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
|
|
77
|
-
this.ctx.on('session/event', (session, event) => {
|
|
77
|
+
this.disposers.push(this.ctx.on('session/event', (session, event) => {
|
|
78
78
|
if (session.id !== this.activeSessionId) return
|
|
79
79
|
if (event.type === 'turn/start') {
|
|
80
80
|
this._inTurn = true
|
|
@@ -91,7 +91,7 @@ export class TelegramConversationNode extends ConversationBridge {
|
|
|
91
91
|
this._streamMsgId = null
|
|
92
92
|
this._streamContent = ''
|
|
93
93
|
}
|
|
94
|
-
})
|
|
94
|
+
}))
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
async _handleInbound(event) {
|
package/lib/tunnel-client.mjs
CHANGED
|
@@ -8,10 +8,11 @@ const RECONNECT_DELAY = 5000;
|
|
|
8
8
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
9
9
|
|
|
10
10
|
export class CustomTunnelClient {
|
|
11
|
-
constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
|
|
11
|
+
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
|
|
12
12
|
this.serverUrl = serverUrl;
|
|
13
13
|
this.accessToken = accessToken;
|
|
14
14
|
this.localPort = localPort;
|
|
15
|
+
this.internalTunnelSecret = internalTunnelSecret;
|
|
15
16
|
this.signal = signal;
|
|
16
17
|
this.onStateChange = onStateChange;
|
|
17
18
|
this.logger = logger;
|
|
@@ -126,10 +127,15 @@ export class CustomTunnelClient {
|
|
|
126
127
|
Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
127
128
|
);
|
|
128
129
|
|
|
130
|
+
const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
|
|
131
|
+
if (this.internalTunnelSecret) {
|
|
132
|
+
reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
|
|
133
|
+
}
|
|
134
|
+
|
|
129
135
|
const req = httpRequest({
|
|
130
136
|
host: '127.0.0.1', port: this.localPort,
|
|
131
137
|
method, path: path || '/',
|
|
132
|
-
headers:
|
|
138
|
+
headers: reqHeaders,
|
|
133
139
|
}, (res) => {
|
|
134
140
|
const chunks = [];
|
|
135
141
|
res.on('data', c => chunks.push(c));
|
|
@@ -168,6 +174,9 @@ export class CustomTunnelClient {
|
|
|
168
174
|
const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
|
|
169
175
|
delete reqHeaders['proxy-connection'];
|
|
170
176
|
delete reqHeaders['proxy-authorization'];
|
|
177
|
+
if (this.internalTunnelSecret) {
|
|
178
|
+
reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
|
|
179
|
+
}
|
|
171
180
|
|
|
172
181
|
const lines = [`GET ${path || '/'} HTTP/1.1`];
|
|
173
182
|
for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
|
package/lib/wechat/media.js
CHANGED
|
@@ -187,9 +187,15 @@ export async function downloadMedia({
|
|
|
187
187
|
const controller = new AbortController()
|
|
188
188
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
189
189
|
try {
|
|
190
|
-
const
|
|
191
|
-
|
|
190
|
+
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
|
|
191
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
192
|
+
if (contentLength > MAX_DOWNLOAD_BYTES) {
|
|
193
|
+
throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
|
|
194
|
+
}
|
|
192
195
|
const raw = Buffer.from(await response.arrayBuffer())
|
|
196
|
+
if (raw.length > MAX_DOWNLOAD_BYTES) {
|
|
197
|
+
throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
|
|
198
|
+
}
|
|
193
199
|
if (aesKeyBase64) {
|
|
194
200
|
const key = parseAesKey(aesKeyBase64)
|
|
195
201
|
return aes128EcbDecrypt(raw, key)
|
package/lib/wechat/node.js
CHANGED
|
@@ -59,9 +59,9 @@ export class WechatConversationNode extends ConversationBridge {
|
|
|
59
59
|
onActiveSessionChange,
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
-
this.ctx.on('wechat/message', (message) => {
|
|
62
|
+
this.disposers.push(this.ctx.on('wechat/message', (message) => {
|
|
63
63
|
void this._handleInbound(message)
|
|
64
|
-
})
|
|
64
|
+
}))
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// ---- 入站 ----
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,15 +18,11 @@
|
|
|
18
18
|
"README.md",
|
|
19
19
|
"README.en.md",
|
|
20
20
|
"LICENSE",
|
|
21
|
-
"docs
|
|
22
|
-
"docs/custom-tunnel.md",
|
|
23
|
-
"docs/wechat-usage.md",
|
|
24
|
-
"docs/qq-usage.md",
|
|
25
|
-
"docs/feishu-usage.md",
|
|
26
|
-
"docs/telegram-usage.md"
|
|
21
|
+
"docs"
|
|
27
22
|
],
|
|
28
23
|
"scripts": {
|
|
29
24
|
"build:client": "node client/build.mjs",
|
|
25
|
+
"prepack": "npm run build:client",
|
|
30
26
|
"test": "node --test test/*.test.mjs"
|
|
31
27
|
},
|
|
32
28
|
"dependencies": {
|
|
@@ -42,7 +38,7 @@
|
|
|
42
38
|
"@deepseek-ai/cordis": "^4.0.1"
|
|
43
39
|
},
|
|
44
40
|
"engines": {
|
|
45
|
-
"node": ">=
|
|
41
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
46
42
|
},
|
|
47
43
|
"license": "MIT",
|
|
48
44
|
"keywords": [
|
|
@@ -62,7 +58,8 @@
|
|
|
62
58
|
"bot",
|
|
63
59
|
"wechat",
|
|
64
60
|
"qq",
|
|
65
|
-
"feishu"
|
|
61
|
+
"feishu",
|
|
62
|
+
"telegram"
|
|
66
63
|
],
|
|
67
64
|
"dsh": {
|
|
68
65
|
"bundle": {
|