@qzsy/dsh-bridge 3.0.0 → 3.0.2
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 +14 -1
- package/README.md +2 -1
- package/lib/config.js +1 -1
- package/lib/dsh-cookie.js +1 -1
- package/lib/gate.js +32 -0
- package/lib/index.js +9 -15
- package/lib/rpc-compat.js +46 -0
- package/lib/saas/tunnel-client.mjs +0 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## [v3.0.2] - 2026-11-01
|
|
4
|
+
|
|
5
|
+
### 🐛 修复
|
|
6
|
+
|
|
7
|
+
- 兼容部分 DSH 版本 connection 服务注册 RPC 通道时的 `webServer without inject` 异常(lib/rpc-compat.js);此前通道注册失败会让面板所有操作返回 HTTP 405。
|
|
8
|
+
|
|
9
|
+
## [v3.0.1] - 2026-11-01
|
|
10
|
+
|
|
11
|
+
### 🔒 安全
|
|
12
|
+
|
|
13
|
+
- 改/清访问口令时轮换会话密钥,旧远程会话立即失效。
|
|
14
|
+
- 门禁登录限流:同一 IP 连续 5 次失败锁定 60 秒(锁定期内正确口令也返回 429)。
|
|
15
|
+
|
|
3
16
|
## [v3.0.0] - 2026-11-01
|
|
4
17
|
|
|
5
18
|
> DSH 远程隧道云官方客户端插件的**首个独立实现版本**。包内所有代码均为本仓库自研,不包含或依赖任何第三方 dsh-bridge 代码。
|
|
@@ -14,7 +27,7 @@
|
|
|
14
27
|
|
|
15
28
|
### ⚠️ 已知限制
|
|
16
29
|
|
|
17
|
-
- `getStatus` 免 admin 引导但只返回脱敏状态;adminToken
|
|
30
|
+
- `getStatus` 免 admin 引导但只返回脱敏状态;adminToken 进程级随机、仅物理回环可领;登录限流 5 次/60 秒;改/清口令会轮换会话密钥(旧会话立即失效);`access.token` 固定可复用(泄露后解绑重绑轮换设备密钥)。
|
|
18
31
|
|
|
19
32
|
### 🔧 工程
|
|
20
33
|
|
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/config.js
CHANGED
|
@@ -35,7 +35,7 @@ export function normalizeConfig(raw = {}) {
|
|
|
35
35
|
const access = input.access && typeof input.access === 'object' ? input.access : {};
|
|
36
36
|
const port = Number(input.port);
|
|
37
37
|
return {
|
|
38
|
-
port: Number.isInteger(port) && port > 0 && port <
|
|
38
|
+
port: Number.isInteger(port) && port > 0 && port < 65_536 ? port : DEFAULT_PORT,
|
|
39
39
|
gatewayUrl: typeof input.gatewayUrl === 'string' && input.gatewayUrl.trim()
|
|
40
40
|
? input.gatewayUrl.trim()
|
|
41
41
|
: DEFAULT_GATEWAY_URL,
|
package/lib/dsh-cookie.js
CHANGED
|
@@ -15,7 +15,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { resolveDshHome } from './data-dir.js';
|
|
17
17
|
|
|
18
|
-
const COOKIE_SPEC = { prefix: 'dsh-auth-', version: 1, ttlMs:
|
|
18
|
+
const COOKIE_SPEC = { prefix: 'dsh-auth-', version: 1, ttlMs: 2_592_000_000 };
|
|
19
19
|
export const DSH_AUTH_COOKIE_PREFIX = COOKIE_SPEC.prefix;
|
|
20
20
|
export const DSH_AUTH_COOKIE_VERSION = COOKIE_SPEC.version;
|
|
21
21
|
export const DSH_AUTH_COOKIE_MAX_AGE_MS = COOKIE_SPEC.ttlMs;
|
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
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// 职责(保持精简,全部为独立实现):
|
|
4
|
-
// 1. 本地门禁代理:监听本机端口 → 转发 DSH 本体,注入 DSH 原生 loopback cookie;
|
|
5
|
-
// 2. SaaS 隧道客户端:设备码绑定 → 短期令牌 → v2 控制通道(转发 HTTP / SSE / WS);
|
|
6
|
-
// 3. 设置页面的 RPC 控制面:状态、绑定、解绑、启停、访问链接。
|
|
7
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
|
|
8
2
|
import { readFileSync } from 'node:fs';
|
|
9
3
|
import { hostname, platform as osPlatform, arch as osArch } from 'node:os';
|
|
10
4
|
import { join, dirname } from 'node:path';
|
|
11
5
|
import { fileURLToPath } from 'node:url';
|
|
12
6
|
import QRCode from 'qrcode';
|
|
13
7
|
|
|
8
|
+
import { registerRpcChannel } from './rpc-compat.js';
|
|
14
9
|
import { PLUGIN_ID, PACKAGE_NAME, DEFAULT_PORT, RPC_CHANNEL, RPC_ENDPOINTS, GATEWAY_PATH } from './identity.js';
|
|
15
10
|
import { resolveConfigPath } from './data-dir.js';
|
|
16
11
|
import {
|
|
17
12
|
ensureConfigSecrets,
|
|
18
13
|
loadConfig,
|
|
19
14
|
maskSecret,
|
|
20
|
-
normalizeConfig,
|
|
21
15
|
saveConfig,
|
|
22
16
|
createSecret,
|
|
23
17
|
hashPassword,
|
|
@@ -55,21 +49,20 @@ function errorPayload(err) {
|
|
|
55
49
|
}
|
|
56
50
|
|
|
57
51
|
function registerRpc(ctx, handler, logger) {
|
|
58
|
-
|
|
59
|
-
if (!rpc?.handle) {
|
|
52
|
+
if (!ctx?.connection?.rpc?.handle) {
|
|
60
53
|
logger?.warn?.('connection RPC unavailable — panel will stay disabled');
|
|
61
54
|
return () => {};
|
|
62
55
|
}
|
|
63
56
|
try {
|
|
64
|
-
const dispose =
|
|
57
|
+
const dispose = registerRpcChannel(ctx, RPC_CHANNEL, handler, { authority: 'loopback' }, logger);
|
|
65
58
|
return typeof dispose === 'function' ? dispose : () => {};
|
|
66
59
|
} catch (err) {
|
|
67
60
|
logger?.warn?.(`RPC register with options failed, retrying without: ${err?.message ?? err}`);
|
|
68
61
|
try {
|
|
69
|
-
const dispose =
|
|
62
|
+
const dispose = registerRpcChannel(ctx, RPC_CHANNEL, handler, undefined, logger);
|
|
70
63
|
return typeof dispose === 'function' ? dispose : () => {};
|
|
71
64
|
} catch (retryErr) {
|
|
72
|
-
logger?.error?.(`RPC register failed: ${retryErr?.message ?? retryErr}`);
|
|
65
|
+
logger?.error?.(`RPC register failed, panel buttons will return 405: ${retryErr?.message ?? retryErr}`);
|
|
73
66
|
return () => {};
|
|
74
67
|
}
|
|
75
68
|
}
|
|
@@ -323,11 +316,12 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
323
316
|
const password = String(payload?.password ?? '');
|
|
324
317
|
if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
|
|
325
318
|
const digest = hashPassword(password);
|
|
326
|
-
|
|
319
|
+
// 轮换会话签名密钥:改口令后所有已登录的远程访客立即失效
|
|
320
|
+
persist({ access: { ...config.access, ...digest, sessionSecret: createSecret(32) } });
|
|
327
321
|
return ok({ passwordSet: true });
|
|
328
322
|
}
|
|
329
323
|
case RPC_ENDPOINTS.clearAccessPassword: {
|
|
330
|
-
persist({ access: { ...config.access, passwordHash: '', passwordSalt: '' } });
|
|
324
|
+
persist({ access: { ...config.access, passwordHash: '', passwordSalt: '', sessionSecret: createSecret(32) } });
|
|
331
325
|
return ok({ passwordSet: false });
|
|
332
326
|
}
|
|
333
327
|
default:
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// RPC 通道注册兼容层(自研)。
|
|
2
|
+
//
|
|
3
|
+
// 部分 DSH 版本的 connection 服务在注册 RPC 通道时,会在它自己的 ctx 上读取 webServer,
|
|
4
|
+
// 而该 ctx 没有声明 inject,cordis 抛出 `cannot get property "webServer" without inject`。
|
|
5
|
+
// 若不处理,通道注册失败,面板仍能渲染,但所有 RPC 调用在宿主侧得到 HTTP 405。
|
|
6
|
+
//
|
|
7
|
+
// 做法:先走公开的 rpc.handle;仅当抛出上述错误时,在 `internal/get` 事件上临时挂一个
|
|
8
|
+
// 监听器,默认解析失败才补上本插件已 inject 的 webServer 单例,调用结束立即摘除。
|
|
9
|
+
|
|
10
|
+
const MISSING_INJECT = /without inject/;
|
|
11
|
+
|
|
12
|
+
export function isMissingWebServerInject(err) {
|
|
13
|
+
return err instanceof Error && MISSING_INJECT.test(err.message) && /webServer/.test(err.message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function installFallback(ctx) {
|
|
17
|
+
const webServer = ctx?.webServer;
|
|
18
|
+
if (webServer === undefined || typeof ctx.on !== 'function') return () => {};
|
|
19
|
+
const listener = (lookupCtx, prop, error, next) => {
|
|
20
|
+
if (prop !== 'webServer') return next();
|
|
21
|
+
try {
|
|
22
|
+
return next();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err !== error) throw err;
|
|
25
|
+
return webServer;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const off = ctx.on('internal/get', listener, { global: true });
|
|
29
|
+
return typeof off === 'function' ? off : () => {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerRpcChannel(ctx, channel, handler, options, logger) {
|
|
33
|
+
const call = () => ctx.connection.rpc.handle(channel, handler, options);
|
|
34
|
+
try {
|
|
35
|
+
return call();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (!isMissingWebServerInject(err)) throw err;
|
|
38
|
+
logger?.warn?.('connection RPC hit webServer inject regression, retrying with fallback');
|
|
39
|
+
const off = installFallback(ctx);
|
|
40
|
+
try {
|
|
41
|
+
return call();
|
|
42
|
+
} finally {
|
|
43
|
+
off();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
8
|
import { request as httpRequest } from 'node:http';
|
|
9
9
|
import { connect as netConnect } from 'node:net';
|
|
10
|
-
import { randomBytes } from 'node:crypto';
|
|
11
10
|
|
|
12
11
|
export const SAAS_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
13
12
|
export const SAAS_HEARTBEAT_MAX_MISSED = 3;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qzsy/dsh-bridge",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
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",
|