@qzsy/dsh-bridge 3.0.0 → 3.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.0.1] - 2026-11-01
4
+
5
+ ### 🔒 安全
6
+
7
+ - 改/清访问口令时轮换会话密钥,旧远程会话立即失效。
8
+ - 门禁登录限流:同一 IP 连续 5 次失败锁定 60 秒(锁定期内正确口令也返回 429)。
9
+
3
10
  ## [v3.0.0] - 2026-11-01
4
11
 
5
12
  > DSH 远程隧道云官方客户端插件的**首个独立实现版本**。包内所有代码均为本仓库自研,不包含或依赖任何第三方 dsh-bridge 代码。
@@ -14,7 +21,7 @@
14
21
 
15
22
  ### ⚠️ 已知限制
16
23
 
17
- - `getStatus` 免 admin 引导但只返回脱敏状态;adminToken 进程级随机、仅物理回环可领;登录页暂无速率限制;`access.token` 固定可复用(泄露后解绑重绑轮换设备密钥)。
24
+ - `getStatus` 免 admin 引导但只返回脱敏状态;adminToken 进程级随机、仅物理回环可领;登录限流 5 次/60 秒;改/清口令会轮换会话密钥(旧会话立即失效);`access.token` 固定可复用(泄露后解绑重绑轮换设备密钥)。
18
25
 
19
26
  ### 🔧 工程
20
27
 
package/README.md CHANGED
@@ -86,7 +86,8 @@ loopback 会话 cookie。来源判定:
86
86
 
87
87
  - `getStatus` 为面板引导接口,免 adminToken,只返回脱敏状态(设备 id/secret 均掩码,不含访问令牌与二维码)。
88
88
  - adminToken 每次进程启动随机生成,只经物理回环 `/__qzsy_bridge__/loopback-token` 签发;重启 DSH 后需重新领取(面板自动完成)。
89
- - 访问口令登录页暂无失败速率限制;请使用足够强的口令,并只在内网/可信网络暴露本地端口。
89
+ - 访问口令登录已内置限流(同一 IP 连续 5 次失败锁定 60 秒);请使用足够强的口令,并只在内网/可信网络暴露本地端口。
90
+ - 修改或清除访问口令会轮换会话签名密钥,所有已登录的远程访客会立即失效。
90
91
  - `access.token` 为固定本地令牌(`?auth=` 链接可重复使用),README 已说明泄露处置方式;吊销设备后请解绑并重新绑定以轮换设备密钥。
91
92
 
92
93
  ## 开发与测试
package/lib/gate.js CHANGED
@@ -20,6 +20,8 @@ export const AUTH_COOKIE = 'qzsy_bridge_auth';
20
20
  export const TUNNEL_HEADER = 'x-dsh-internal-tunnel';
21
21
  export const SAAS_USER_HEADER = 'x-saas-user';
22
22
  const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
23
+ const LOGIN_MAX_FAILURES = 5;
24
+ const LOGIN_LOCK_MS = 60 * 1000;
23
25
  const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
24
26
  const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
25
27
 
@@ -93,6 +95,7 @@ export class GateProxy {
93
95
  this.adminToken = adminToken || `adm_${randomBytes(24).toString('base64url')}`;
94
96
  this.server = null;
95
97
  this.listeningPort = null;
98
+ this.loginFailures = new Map();
96
99
  }
97
100
 
98
101
  /** 关闭会话签名:v1.<expiresAt>.<hmac(expiresAt)>;不落盘、无服务端会话表。 */
@@ -168,6 +171,27 @@ export class GateProxy {
168
171
  <div><h1>尚未设置访问口令</h1><p>DSH 远程隧道云默认拒绝未认证的远程/隧道访客。</p><p>请先在<b>本机</b> DSH 设置页「远程访问」中设置访问口令,再把「带访问令牌的链接」发给需要访问的人。</p></div></html>`;
169
172
  }
170
173
 
174
+ _loginBlocked(address) {
175
+ const entry = this.loginFailures.get(address);
176
+ if (!entry) return false;
177
+ if (entry.lockedUntil && entry.lockedUntil > this.now()) return true;
178
+ if (entry.lockedUntil && entry.lockedUntil <= this.now()) {
179
+ this.loginFailures.delete(address);
180
+ }
181
+ return false;
182
+ }
183
+
184
+ _recordLoginFailure(address) {
185
+ const now = this.now();
186
+ const entry = this.loginFailures.get(address);
187
+ if (!entry || now - entry.firstAt > LOGIN_LOCK_MS) {
188
+ this.loginFailures.set(address, { count: 1, firstAt: now, lockedUntil: 0 });
189
+ return;
190
+ }
191
+ entry.count += 1;
192
+ if (entry.count >= LOGIN_MAX_FAILURES) entry.lockedUntil = now + LOGIN_LOCK_MS;
193
+ }
194
+
171
195
  _corsHeaders(req) {
172
196
  const origin = String(req?.headers?.origin ?? '');
173
197
  if (!origin) return {};
@@ -218,13 +242,21 @@ export class GateProxy {
218
242
  return true;
219
243
  }
220
244
  if (req.method === 'POST') {
245
+ const address = String(req.socket?.remoteAddress ?? 'unknown');
246
+ if (this._loginBlocked(address)) {
247
+ res.writeHead(429, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '60' });
248
+ res.end(this._loginPage(url.searchParams.get('next') || '/', '尝试次数过多,请 60 秒后再试'));
249
+ return true;
250
+ }
221
251
  const body = await this._readBody(req);
222
252
  const password = new URLSearchParams(body).get('password') ?? '';
223
253
  if (!verifyPassword(password, this.config?.access ?? {})) {
254
+ this._recordLoginFailure(address);
224
255
  res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
225
256
  res.end(this._loginPage(url.searchParams.get('next') || '/', '口令不正确'));
226
257
  return true;
227
258
  }
259
+ this.loginFailures.delete(address);
228
260
  this._setSessionCookie(res);
229
261
  res.writeHead(302, { location: url.searchParams.get('next') || '/', 'cache-control': 'no-store' });
230
262
  res.end();
package/lib/index.js CHANGED
@@ -323,11 +323,12 @@ export async function apply(ctx, pluginConfig = {}) {
323
323
  const password = String(payload?.password ?? '');
324
324
  if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
325
325
  const digest = hashPassword(password);
326
- persist({ access: { ...config.access, ...digest } });
326
+ // 轮换会话签名密钥:改口令后所有已登录的远程访客立即失效
327
+ persist({ access: { ...config.access, ...digest, sessionSecret: createSecret(32) } });
327
328
  return ok({ passwordSet: true });
328
329
  }
329
330
  case RPC_ENDPOINTS.clearAccessPassword: {
330
- persist({ access: { ...config.access, passwordHash: '', passwordSalt: '' } });
331
+ persist({ access: { ...config.access, passwordHash: '', passwordSalt: '', sessionSecret: createSecret(32) } });
331
332
  return ok({ passwordSet: false });
332
333
  }
333
334
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-bridge",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
5
  "releaseNotes": "【v3.0.0】官方客户端插件首个独立实现版本\n• 全新自研代码:插件骨架、本地门禁代理、SaaS 隧道客户端与设置面板均独立实现\n• 设备码绑定 + 短期设备令牌 + v2 控制通道(心跳/路由热更新/drain/终态吊销)\n• 本地门禁:回环免认证、隧道来源用进程内随机密钥标记、局域网访问需本地口令\n• 支持 HTTP / SSE 流式 / WebSocket 转发,公网地址复制与二维码\n• 数据目录 ~/.dsh/qzsy-dsh-bridge,默认本地端口 3092(可配置)",
6
6
  "type": "module",