@wenbin_wb/dsh-bridge 2.4.0 → 2.5.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/README.en.md +45 -8
- package/README.md +45 -8
- package/client/client.js +730 -12
- package/client/index.js +615 -15
- package/lib/auth/login-template.js +378 -0
- package/lib/auth/manager.js +425 -0
- package/lib/bridge-rpc-constants.js +6 -0
- package/lib/bridge-rpc.js +34 -1
- package/lib/index.js +203 -14
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -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
|
|
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?.getStatus() ?? { 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
|
|
|
@@ -291,11 +398,38 @@ class BridgeService {
|
|
|
291
398
|
|
|
292
399
|
async getStatus() {
|
|
293
400
|
const lanIp = selectLanIPv4();
|
|
294
|
-
const
|
|
401
|
+
const token = this.authManager?.secretToken;
|
|
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() ?? { 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
|
},
|
|
@@ -495,6 +632,7 @@ function apply(ctx, config = {}) {
|
|
|
495
632
|
const proxyPort = config.port ?? 3082;
|
|
496
633
|
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
497
634
|
const configFile = join(dshHome, 'dsh-bridge', 'config.json');
|
|
635
|
+
const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
|
|
498
636
|
|
|
499
637
|
// 从 JSON 文件读取持久化配置
|
|
500
638
|
async function loadConfig() {
|
|
@@ -512,11 +650,60 @@ function apply(ctx, config = {}) {
|
|
|
512
650
|
await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
|
|
513
651
|
}
|
|
514
652
|
|
|
653
|
+
// 访问安全认证管理器
|
|
654
|
+
const authManager = new AuthManager({
|
|
655
|
+
config: config.auth ?? {},
|
|
656
|
+
logger,
|
|
657
|
+
onPersist: async (patch) => {
|
|
658
|
+
const stored = await loadConfig();
|
|
659
|
+
stored.auth = { ...(stored.auth ?? {}), ...patch };
|
|
660
|
+
await saveConfig(stored);
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
|
|
665
|
+
async function checkEmergencyReset() {
|
|
666
|
+
try {
|
|
667
|
+
await unlink(emergencyResetFile);
|
|
668
|
+
authManager.enabled = false;
|
|
669
|
+
authManager.passwordHash = '';
|
|
670
|
+
authManager.passwordSalt = '';
|
|
671
|
+
authManager.adminPasswordHash = '';
|
|
672
|
+
authManager.adminPasswordSalt = '';
|
|
673
|
+
authManager.adminPolicy = 'password_unlock';
|
|
674
|
+
authManager.mode = 'token_and_password';
|
|
675
|
+
authManager.sessions.clear();
|
|
676
|
+
authManager.adminSessions.clear();
|
|
677
|
+
const stored = await loadConfig();
|
|
678
|
+
delete stored.auth;
|
|
679
|
+
await saveConfig(stored);
|
|
680
|
+
logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
|
|
681
|
+
} catch {}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 启动时读取已保存的 auth 配置并执行保命标记检查
|
|
685
|
+
checkEmergencyReset().then(() => loadConfig()).then((stored) => {
|
|
686
|
+
if (stored?.auth) {
|
|
687
|
+
if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
|
|
688
|
+
if (stored.auth.mode) authManager.mode = stored.auth.mode;
|
|
689
|
+
if (stored.auth.scope) authManager.scope = stored.auth.scope;
|
|
690
|
+
if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
|
|
691
|
+
if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
|
|
692
|
+
if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
|
|
693
|
+
if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
|
|
694
|
+
if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
|
|
695
|
+
if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
|
|
696
|
+
if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
|
|
697
|
+
logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
|
|
698
|
+
}
|
|
699
|
+
}).catch(() => {});
|
|
700
|
+
|
|
515
701
|
const service = new BridgeService({
|
|
516
702
|
dshPort,
|
|
517
703
|
proxyPort,
|
|
518
704
|
home: config.home,
|
|
519
705
|
customTunnelConfig: config.customTunnel ?? null,
|
|
706
|
+
authManager,
|
|
520
707
|
logger,
|
|
521
708
|
});
|
|
522
709
|
|
|
@@ -735,6 +922,7 @@ function apply(ctx, config = {}) {
|
|
|
735
922
|
|
|
736
923
|
const disposeRpc = installBridgeRpc(ctx, {
|
|
737
924
|
service,
|
|
925
|
+
authManager,
|
|
738
926
|
wechat,
|
|
739
927
|
qq,
|
|
740
928
|
feishu,
|
|
@@ -761,8 +949,9 @@ function apply(ctx, config = {}) {
|
|
|
761
949
|
await feishu.destroy();
|
|
762
950
|
await telegram.destroy();
|
|
763
951
|
platformManager.dispose();
|
|
952
|
+
authManager.dispose();
|
|
764
953
|
await service.dispose();
|
|
765
|
-
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy and tunnels');
|
|
954
|
+
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
|
|
766
955
|
}
|
|
767
956
|
|
|
768
|
-
export { name, inject, apply };
|
|
957
|
+
export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|