@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/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
- * 否则手机通过局域网访问时 DSH 会把它当未登录的外部请求处理
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(DSH 的 /api/events.mux 等流式通道)
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 lanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
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: this.cloudflared?.url || null,
314
- qr: this.cloudflared?.url
315
- ? await this.qrCache.get(this.cloudflared.url)
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: this.customTunnel?.publicUrl || null,
325
- qr: this.customTunnel?.publicUrl
326
- ? await this.qrCache.get(this.customTunnel.publicUrl)
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.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",