@qzsy/dsh-bridge 3.0.1 → 3.1.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/CHANGELOG.md +23 -0
- package/README.md +23 -19
- package/client/client.js +96 -50
- package/client/index.js +57 -42
- package/client/locales.mjs +18 -2
- package/lib/config.js +1 -1
- package/lib/dsh-cookie.js +1 -1
- package/lib/gate.js +53 -5
- package/lib/identity.js +2 -0
- package/lib/index.js +70 -20
- package/lib/rpc-compat.js +46 -0
- package/lib/saas/tunnel-client.mjs +8 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## [v3.0.1] - 2026-11-01
|
|
4
10
|
|
|
5
11
|
### 🔒 安全
|
|
@@ -7,6 +13,23 @@
|
|
|
7
13
|
- 改/清访问口令时轮换会话密钥,旧远程会话立即失效。
|
|
8
14
|
- 门禁登录限流:同一 IP 连续 5 次失败锁定 60 秒(锁定期内正确口令也返回 429)。
|
|
9
15
|
|
|
16
|
+
## [v3.1.0] - 2026-11-01
|
|
17
|
+
|
|
18
|
+
> 无感登录授权与扫码进入:点「立即登录授权」→ 浏览器授权 → 面板自动绑定并出现二维码,全程不输入设备码、不设本地口令。
|
|
19
|
+
|
|
20
|
+
### ✨ 新增
|
|
21
|
+
|
|
22
|
+
- **startLogin RPC + 面板按钮**:自动调用设备码接口,返回 `/device/authorize?code=...` 并打开系统浏览器;后台沿用 pairing 轮询,授权后自动 claim、自动开启隧道。
|
|
23
|
+
- **扫码进入**:二维码内容改为 `<控制面>/open/<publicId>`(不再使用网关地址或本地 `?auth=` 令牌);面板可一键显示/收起二维码。
|
|
24
|
+
- **网关信任头 `x-qzsy-gate`**:格式 `ts.uid.hmac`,HMAC-SHA256、±60 秒;验签通过即认证,隧道来源未验签一律 401,不再要求本地口令。
|
|
25
|
+
- **每连接 gate.secret**:hello-ack 携带 `gate.secret`(32 字节 hex),断线/drain/终态立即清空;转发 DSH 前剥离 `x-qzsy-gate` 与隧道标记。
|
|
26
|
+
- **无感绑定不要求口令**:startTunnel/自动启动移除口令前置;本地口令保留为「高级」可选局域网能力。
|
|
27
|
+
|
|
28
|
+
### ✅ 验证
|
|
29
|
+
|
|
30
|
+
- 单测覆盖 gate 验签(合法/过期/篡改/伪造/无密钥/剥离)、startLogin 全流程(模拟控制面)、gate.secret 生命周期、`/open/<publicId>` 二维码。
|
|
31
|
+
- 全量单测、网关 e2e、插件级 e2e 保持全绿。
|
|
32
|
+
|
|
10
33
|
## [v3.0.0] - 2026-11-01
|
|
11
34
|
|
|
12
35
|
> DSH 远程隧道云官方客户端插件的**首个独立实现版本**。包内所有代码均为本仓库自研,不包含或依赖任何第三方 dsh-bridge 代码。
|
package/README.md
CHANGED
|
@@ -18,18 +18,20 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
|
|
|
18
18
|
|
|
19
19
|
## 绑定设备(接入 DSH 远程隧道云)
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
无感授权流程(推荐,全程不需要输入设备码、也不需要设本地口令):
|
|
22
|
+
|
|
22
23
|
1. 面板中确认 **SaaS 网关地址**:官方云为
|
|
23
24
|
`wss://dsh.b.nps.qzsyzn.com/_gw/connect`(自建部署填自己的网关地址),点「保存网关」。
|
|
24
|
-
2.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
4.
|
|
28
|
-
5.
|
|
25
|
+
2. 点「**立即登录授权**」:插件自动申请设备码并在系统浏览器打开
|
|
26
|
+
`https://<控制面>/device/authorize?code=...`。
|
|
27
|
+
3. 在浏览器登录 SaaS 账号 → 点「**授权此设备**」→ 回到 DSH 面板。
|
|
28
|
+
4. 面板会自动轮询并完成绑定,**绑定后自动开启隧道**,随后出现「**显示二维码**」按钮。
|
|
29
|
+
5. 手机扫码(`https://<控制面>/open/<publicId>`)→ 登录 SaaS 账号 → 自动验证隧道归属并签发一次性
|
|
30
|
+
ticket,跳转进入本机 DSH。
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
`~/.dsh/qzsy-dsh-bridge/config.json
|
|
32
|
-
|
|
32
|
+
> 需要手动输入设备码时,可展开面板「高级:手动设备码 / 局域网口令」,用「获取设备码」走旧流程。
|
|
33
|
+
> 绑定信息(`deviceId` / `deviceSecret`)只保存在本机 `~/.dsh/qzsy-dsh-bridge/config.json`
|
|
34
|
+
> (权限 0600);面板只显示掩码。解绑会清除本机凭证,设备本身需在控制台吊销。
|
|
33
35
|
|
|
34
36
|
## 公网地址形态
|
|
35
37
|
|
|
@@ -46,13 +48,15 @@ loopback 会话 cookie。来源判定:
|
|
|
46
48
|
| 来源 | 判定 | 结果 |
|
|
47
49
|
| --- | --- | --- |
|
|
48
50
|
| 物理本机回环 | 连接来自 `127.0.0.1` / `::1` 且未带隧道标记 | 免认证,享有本机特权 |
|
|
49
|
-
| SaaS 隧道 |
|
|
50
|
-
| 局域网 / 其它直连 | 非回环且无有效隧道标记 |
|
|
51
|
+
| SaaS 隧道 | 携带 `x-dsh-internal-tunnel` 标记,并带网关注入的 `x-qzsy-gate: ts.uid.hmac` | 验签通过(HMAC-SHA256、±60 秒)即认证;未验签一律 401,不再要求本地口令 |
|
|
52
|
+
| 局域网 / 其它直连 | 非回环且无有效隧道标记 | 需本地口令登录或 `?auth=` 令牌换取会话(可选能力) |
|
|
51
53
|
|
|
52
54
|
- `/__qzsy_bridge__/loopback-token` 只对物理回环签发;经隧道访问一律 **403**。
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
-
|
|
55
|
+
- 隧道访客由**网关信任头**认证:hello-ack 为每条连接下发随机 32 字节 `gate.secret`,网关在校验一次性 ticket
|
|
56
|
+
且账号为隧道 owner 后附加 `x-qzsy-gate`;插件验签通过即放行,不依赖本地口令。
|
|
57
|
+
- `x-qzsy-gate` 由网关先剥掉客户端伪造值再附加;插件转发 DSH 前也会剥离该头与隧道标记。
|
|
58
|
+
- 本地口令是**可选的局域网直连能力**(面板「高级」里可设置),与 SaaS 扫码进入互不影响;
|
|
59
|
+
`?auth=` 固定令牌同样只用于局域网/直连,请只发给可信设备。
|
|
56
60
|
|
|
57
61
|
## 配置
|
|
58
62
|
|
|
@@ -73,18 +77,18 @@ loopback 会话 cookie。来源判定:
|
|
|
73
77
|
|
|
74
78
|
| 现象 | 排查 |
|
|
75
79
|
| --- | --- |
|
|
76
|
-
| 面板显示「未绑定」 |
|
|
77
|
-
|
|
|
80
|
+
| 面板显示「未绑定」 | 确认网关地址可访问;重新点「立即登录授权」,确认浏览器里已点「授权此设备」 |
|
|
81
|
+
| 授权页提示设备码无效/过期 | 设备码 10 分钟有效,回面板重新点「立即登录授权」 |
|
|
78
82
|
| 开启隧道一直「连接中」 | 检查 `wss://.../_gw/connect` 是否可直连(公司代理/防火墙可能拦截 WebSocket) |
|
|
79
83
|
| 握手失败 `auth_failed` / `revoked` | 设备已吊销或令牌失效:解绑后重新绑定;被吊销需在控制台重新添加设备 |
|
|
80
|
-
| 打开公网地址 401 |
|
|
84
|
+
| 打开公网地址 401 | 隧道来源只认网关 `x-qzsy-gate` 签名:确认控制台/网关已升级到支持无感授权(hello-ack 下发 gate.secret),并重新扫码获取新 ticket |
|
|
81
85
|
| 本机 3092 被占用 | 在 `config.json` 或 cordis 配置里改 `port` |
|
|
82
86
|
| 请求 502 | DSH 本体不可达:确认 `dsh web` 在运行,或配置 `targetPort` |
|
|
83
87
|
| WebSocket 打不开 | 确认网关与反代放行 WS Upgrade;插件会透传 `ws-open/accept/frame/close` |
|
|
84
88
|
|
|
85
|
-
## 已知限制(v3.
|
|
89
|
+
## 已知限制(v3.1.0)
|
|
86
90
|
|
|
87
|
-
- `getStatus` 为面板引导接口,免 adminToken,只返回脱敏状态(设备 id/secret
|
|
91
|
+
- `getStatus` 为面板引导接口,免 adminToken,只返回脱敏状态(设备 id/secret 均掩码,不含访问令牌与二维码;`openUrl` 由 /open/<publicId> 二次校验归属)。
|
|
88
92
|
- adminToken 每次进程启动随机生成,只经物理回环 `/__qzsy_bridge__/loopback-token` 签发;重启 DSH 后需重新领取(面板自动完成)。
|
|
89
93
|
- 访问口令登录已内置限流(同一 IP 连续 5 次失败锁定 60 秒);请使用足够强的口令,并只在内网/可信网络暴露本地端口。
|
|
90
94
|
- 修改或清除访问口令会轮换会话签名密钥,所有已登录的远程访客会立即失效。
|
package/client/client.js
CHANGED
|
@@ -46,6 +46,8 @@ var RPC_ENDPOINTS = Object.freeze({
|
|
|
46
46
|
startTunnel: "startTunnel",
|
|
47
47
|
stopTunnel: "stopTunnel",
|
|
48
48
|
getAccessUrl: "getAccessUrl",
|
|
49
|
+
startLogin: "startLogin",
|
|
50
|
+
getEntryQr: "getEntryQr",
|
|
49
51
|
setAccessPassword: "setAccessPassword",
|
|
50
52
|
clearAccessPassword: "clearAccessPassword"
|
|
51
53
|
});
|
|
@@ -61,6 +63,14 @@ var zh = {
|
|
|
61
63
|
notBound: "\u672A\u7ED1\u5B9A",
|
|
62
64
|
bound: "\u5DF2\u7ED1\u5B9A",
|
|
63
65
|
getCode: "\u83B7\u53D6\u8BBE\u5907\u7801",
|
|
66
|
+
loginNow: "\u7ACB\u5373\u767B\u5F55\u6388\u6743",
|
|
67
|
+
loginWaiting: "\u5DF2\u6253\u5F00\u6D4F\u89C8\u5668\u6388\u6743\u9875\uFF0C\u8BF7\u5728\u6D4F\u89C8\u5668\u4E2D\u5B8C\u6210\u767B\u5F55\u5E76\u70B9\u300C\u6388\u6743\u6B64\u8BBE\u5907\u300D\uFF1B\u6388\u6743\u540E\u672C\u9875\u4F1A\u81EA\u52A8\u5237\u65B0\u3002",
|
|
68
|
+
reopenAuthorize: "\u91CD\u65B0\u6253\u5F00\u6388\u6743\u9875",
|
|
69
|
+
advanced: "\u9AD8\u7EA7\uFF1A\u624B\u52A8\u8BBE\u5907\u7801 / \u5C40\u57DF\u7F51\u53E3\u4EE4\uFF08\u53EF\u9009\uFF09",
|
|
70
|
+
entryUrl: "\u626B\u7801\u5165\u53E3\uFF08SaaS \u767B\u5F55\u540E\u8FDB\u5165\uFF09",
|
|
71
|
+
showQr: "\u663E\u793A\u4E8C\u7EF4\u7801",
|
|
72
|
+
hideQr: "\u6536\u8D77\u4E8C\u7EF4\u7801",
|
|
73
|
+
qrLoading: "\u4E8C\u7EF4\u7801\u751F\u6210\u4E2D\u2026",
|
|
64
74
|
codeTitle: "\u5728\u63A7\u5236\u53F0\u6DFB\u52A0\u8BBE\u5907\u5E76\u8F93\u5165\u6B64\u8BBE\u5907\u7801",
|
|
65
75
|
codeExpires: "\u8BBE\u5907\u7801 10 \u5206\u949F\u5185\u6709\u6548\uFF0C\u7ED1\u5B9A\u540E\u81EA\u52A8\u6D88\u5931\u3002",
|
|
66
76
|
openConsole: "\u6253\u5F00\u63A7\u5236\u53F0",
|
|
@@ -82,7 +92,7 @@ var zh = {
|
|
|
82
92
|
qr: "\u626B\u7801\u6253\u5F00",
|
|
83
93
|
lastError: "\u6700\u8FD1\u9519\u8BEF",
|
|
84
94
|
password: "\u8BBF\u95EE\u53E3\u4EE4",
|
|
85
|
-
passwordHint: "\
|
|
95
|
+
passwordHint: "\u53EF\u9009\uFF1A\u4E3A\u5C40\u57DF\u7F51\u76F4\u8FDE\u8BBE\u7F6E\u672C\u5730\u53E3\u4EE4\uFF1BSaaS \u96A7\u9053\u8BBF\u5BA2\u8D70\u7F51\u5173\u7B7E\u540D\uFF0C\u65E0\u9700\u53E3\u4EE4\u3002",
|
|
86
96
|
passwordSet: "\u5DF2\u8BBE\u7F6E\u8BBF\u95EE\u53E3\u4EE4\uFF08\u53EF\u5728\u6B64\u4FEE\u6539\uFF09\u3002",
|
|
87
97
|
passwordPlaceholder: "\u81F3\u5C11 6 \u4F4D",
|
|
88
98
|
savePassword: "\u4FDD\u5B58\u53E3\u4EE4",
|
|
@@ -103,6 +113,14 @@ var en = {
|
|
|
103
113
|
notBound: "Not bound",
|
|
104
114
|
bound: "Bound",
|
|
105
115
|
getCode: "Get device code",
|
|
116
|
+
loginNow: "Log in & authorize",
|
|
117
|
+
loginWaiting: "The authorization page is open in your browser. Sign in and click \u201CAuthorize this device\u201D; this panel will refresh automatically.",
|
|
118
|
+
reopenAuthorize: "Reopen authorization page",
|
|
119
|
+
advanced: "Advanced: manual device code / LAN password (optional)",
|
|
120
|
+
entryUrl: "Scan entry (sign in to SaaS first)",
|
|
121
|
+
showQr: "Show QR code",
|
|
122
|
+
hideQr: "Hide QR code",
|
|
123
|
+
qrLoading: "Generating QR code\u2026",
|
|
106
124
|
codeTitle: "Add the device in the console and enter this code",
|
|
107
125
|
codeExpires: "The code is valid for 10 minutes and disappears after binding.",
|
|
108
126
|
openConsole: "Open console",
|
|
@@ -124,7 +142,7 @@ var en = {
|
|
|
124
142
|
qr: "Scan to open",
|
|
125
143
|
lastError: "Last error",
|
|
126
144
|
password: "Access password",
|
|
127
|
-
passwordHint: "
|
|
145
|
+
passwordHint: "Optional: set a local password for LAN access. SaaS tunnel visitors are authenticated by the gateway signature and do not need it.",
|
|
128
146
|
passwordSet: "Access password is set (you can change it here).",
|
|
129
147
|
passwordPlaceholder: "At least 6 characters",
|
|
130
148
|
savePassword: "Save password",
|
|
@@ -215,8 +233,8 @@ function BridgePanel() {
|
|
|
215
233
|
const [error, setError] = React.useState("");
|
|
216
234
|
const [notice, setNotice] = React.useState("");
|
|
217
235
|
const [confirmUnbind, setConfirmUnbind] = React.useState(false);
|
|
218
|
-
const [
|
|
219
|
-
const [
|
|
236
|
+
const [showQr, setShowQr] = React.useState(false);
|
|
237
|
+
const [entryQr, setEntryQr] = React.useState("");
|
|
220
238
|
const [passwordDraft, setPasswordDraft] = React.useState("");
|
|
221
239
|
const loadAdminToken = React.useCallback(async (port) => {
|
|
222
240
|
if (!port) return;
|
|
@@ -248,22 +266,20 @@ function BridgePanel() {
|
|
|
248
266
|
return () => clearInterval(timer);
|
|
249
267
|
}, [refresh]);
|
|
250
268
|
React.useEffect(() => {
|
|
251
|
-
if (!adminToken || !status?.
|
|
252
|
-
|
|
253
|
-
setQrDataUrl("");
|
|
269
|
+
if (!adminToken || !showQr || !status?.openUrl) {
|
|
270
|
+
setEntryQr("");
|
|
254
271
|
return;
|
|
255
272
|
}
|
|
256
273
|
let cancelled = false;
|
|
257
|
-
rpc(RPC_ENDPOINTS.
|
|
274
|
+
rpc(RPC_ENDPOINTS.getEntryQr).then((res) => {
|
|
258
275
|
if (cancelled || !res?.ok) return;
|
|
259
|
-
|
|
260
|
-
setQrDataUrl(res.value?.qrDataUrl ?? "");
|
|
276
|
+
setEntryQr(res.value?.qrDataUrl ?? "");
|
|
261
277
|
}).catch(() => {
|
|
262
278
|
});
|
|
263
279
|
return () => {
|
|
264
280
|
cancelled = true;
|
|
265
281
|
};
|
|
266
|
-
}, [adminToken, status?.
|
|
282
|
+
}, [adminToken, showQr, status?.openUrl, rpc]);
|
|
267
283
|
const run = React.useCallback(async (key, fn) => {
|
|
268
284
|
setBusy(key);
|
|
269
285
|
setError("");
|
|
@@ -282,6 +298,31 @@ function BridgePanel() {
|
|
|
282
298
|
}, [refresh]);
|
|
283
299
|
const saveGateway = () => run("gateway", () => rpc(RPC_ENDPOINTS.saveGateway, { gatewayUrl: gatewayDraft })).then(() => setNotice(t("saved")));
|
|
284
300
|
const requestCode = () => run("code", () => rpc(RPC_ENDPOINTS.requestDeviceCode));
|
|
301
|
+
const startLogin = () => {
|
|
302
|
+
let popup = null;
|
|
303
|
+
try {
|
|
304
|
+
popup = window.open("about:blank", "_blank");
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
return run("login", () => rpc(RPC_ENDPOINTS.startLogin)).then((value) => {
|
|
308
|
+
if (!value?.verificationUrl) {
|
|
309
|
+
try {
|
|
310
|
+
popup?.close();
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
if (popup && !popup.closed) popup.location.href = value.verificationUrl;
|
|
317
|
+
else window.open(value.verificationUrl, "_blank", "noopener,noreferrer");
|
|
318
|
+
} catch {
|
|
319
|
+
try {
|
|
320
|
+
window.open(value.verificationUrl, "_blank", "noopener,noreferrer");
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
};
|
|
285
326
|
const bindCode = () => run("bind", () => rpc(RPC_ENDPOINTS.bindDevice, { code: codeDraft })).then((value) => {
|
|
286
327
|
if (value) setCodeDraft("");
|
|
287
328
|
});
|
|
@@ -299,6 +340,7 @@ function BridgePanel() {
|
|
|
299
340
|
const phase = status?.phase ?? "idle";
|
|
300
341
|
const publicUrl = status?.publicUrl ?? "";
|
|
301
342
|
const pairing = status?.pairing;
|
|
343
|
+
const openUrl = status?.openUrl ?? "";
|
|
302
344
|
const passwordSet = Boolean(status?.access?.passwordSet);
|
|
303
345
|
const stateText = connected ? t("ready") : phase === "connecting" || phase === "reconnecting" ? t("connecting") : t("offline");
|
|
304
346
|
return h(
|
|
@@ -356,7 +398,7 @@ function BridgePanel() {
|
|
|
356
398
|
bound ? `${status?.device?.tunnel?.name || "DSH"} \xB7 ${status?.device?.tunnel?.publicId || ""}` : t("notBound")
|
|
357
399
|
)
|
|
358
400
|
),
|
|
359
|
-
!bound && h("button", { style: styles.btnPrimary, disabled: Boolean(busy), onClick:
|
|
401
|
+
!bound && h("button", { style: styles.btnPrimary, disabled: Boolean(busy), onClick: startLogin }, t("loginNow")),
|
|
360
402
|
bound && h("button", {
|
|
361
403
|
style: styles.btn,
|
|
362
404
|
disabled: Boolean(busy),
|
|
@@ -366,22 +408,44 @@ function BridgePanel() {
|
|
|
366
408
|
pairing && pairing.status === "pending" ? h(
|
|
367
409
|
"div",
|
|
368
410
|
{ style: { marginTop: 12 } },
|
|
369
|
-
h("div", { style: styles.muted }, t("
|
|
370
|
-
h("
|
|
371
|
-
h("div", { style: styles.muted }, t("codeExpires")),
|
|
372
|
-
pairing.verificationUrl ? h("a", { href: pairing.verificationUrl, target: "_blank", rel: "noreferrer", style: { fontSize: 12 } }, t("openConsole")) : null
|
|
411
|
+
h("div", { style: styles.muted }, t("loginWaiting")),
|
|
412
|
+
pairing.verificationUrl ? h("a", { href: pairing.verificationUrl, target: "_blank", rel: "noreferrer", style: { fontSize: 12 } }, t("reopenAuthorize")) : null
|
|
373
413
|
) : null,
|
|
374
|
-
|
|
375
|
-
"
|
|
414
|
+
h(
|
|
415
|
+
"details",
|
|
376
416
|
{ style: { marginTop: 12 } },
|
|
377
|
-
h("
|
|
417
|
+
h("summary", { style: { ...styles.muted, cursor: "pointer" } }, t("advanced")),
|
|
418
|
+
!bound ? h(
|
|
419
|
+
"div",
|
|
420
|
+
{ style: { marginTop: 10 } },
|
|
421
|
+
h("span", { style: styles.label }, t("bindManual")),
|
|
422
|
+
h(
|
|
423
|
+
"div",
|
|
424
|
+
{ style: styles.row },
|
|
425
|
+
h("input", { style: styles.input, value: codeDraft, onChange: (e) => setCodeDraft(e.target.value), placeholder: "ABCD-1234" }),
|
|
426
|
+
h("button", { style: styles.btn, disabled: busy === "bind" || !codeDraft, onClick: bindCode }, t("bind")),
|
|
427
|
+
h("button", { style: styles.btn, disabled: Boolean(busy), onClick: requestCode }, t("getCode"))
|
|
428
|
+
)
|
|
429
|
+
) : null,
|
|
378
430
|
h(
|
|
379
431
|
"div",
|
|
380
|
-
{ style:
|
|
381
|
-
h("
|
|
382
|
-
h("
|
|
432
|
+
{ style: { marginTop: 12 } },
|
|
433
|
+
h("div", { style: { fontSize: 13, fontWeight: 600 } }, t("password")),
|
|
434
|
+
h("div", { style: styles.muted }, passwordSet ? t("passwordSet") : t("passwordHint")),
|
|
435
|
+
h(
|
|
436
|
+
"div",
|
|
437
|
+
{ style: { ...styles.row, marginTop: 8 } },
|
|
438
|
+
h("input", {
|
|
439
|
+
style: styles.input,
|
|
440
|
+
type: "password",
|
|
441
|
+
value: passwordDraft,
|
|
442
|
+
onChange: (e) => setPasswordDraft(e.target.value),
|
|
443
|
+
placeholder: t("passwordPlaceholder")
|
|
444
|
+
}),
|
|
445
|
+
h("button", { style: styles.btn, disabled: busy === "password" || passwordDraft.length < 6, onClick: savePassword }, t("savePassword"))
|
|
446
|
+
)
|
|
383
447
|
)
|
|
384
|
-
)
|
|
448
|
+
),
|
|
385
449
|
confirmUnbind ? h(
|
|
386
450
|
"div",
|
|
387
451
|
{ style: { marginTop: 12 } },
|
|
@@ -394,24 +458,6 @@ function BridgePanel() {
|
|
|
394
458
|
)
|
|
395
459
|
) : null
|
|
396
460
|
),
|
|
397
|
-
h(
|
|
398
|
-
"div",
|
|
399
|
-
{ style: styles.card },
|
|
400
|
-
h("div", { style: { fontSize: 13, fontWeight: 600 } }, t("password")),
|
|
401
|
-
h("div", { style: styles.muted }, passwordSet ? t("passwordSet") : t("passwordHint")),
|
|
402
|
-
h(
|
|
403
|
-
"div",
|
|
404
|
-
{ style: { ...styles.row, marginTop: 8 } },
|
|
405
|
-
h("input", {
|
|
406
|
-
style: styles.input,
|
|
407
|
-
type: "password",
|
|
408
|
-
value: passwordDraft,
|
|
409
|
-
onChange: (e) => setPasswordDraft(e.target.value),
|
|
410
|
-
placeholder: t("passwordPlaceholder")
|
|
411
|
-
}),
|
|
412
|
-
h("button", { style: styles.btnPrimary, disabled: busy === "password" || passwordDraft.length < 6, onClick: savePassword }, t("savePassword"))
|
|
413
|
-
)
|
|
414
|
-
),
|
|
415
461
|
h(
|
|
416
462
|
"div",
|
|
417
463
|
{ style: styles.card },
|
|
@@ -424,22 +470,22 @@ function BridgePanel() {
|
|
|
424
470
|
h("div", { style: { fontSize: 13, fontWeight: 600 } }, t("tunnel")),
|
|
425
471
|
h("div", { style: styles.muted }, connected ? publicUrl : t("noPublicUrl"))
|
|
426
472
|
),
|
|
427
|
-
connected ? h("button", { style: styles.btn, disabled: Boolean(busy), onClick: stopTunnel }, t("stop")) : h("button", { style: styles.btnPrimary, disabled: Boolean(busy) || !bound
|
|
473
|
+
connected ? h("button", { style: styles.btn, disabled: Boolean(busy), onClick: stopTunnel }, t("stop")) : h("button", { style: styles.btnPrimary, disabled: Boolean(busy) || !bound, onClick: startTunnel }, t("start"))
|
|
428
474
|
),
|
|
429
|
-
|
|
475
|
+
openUrl ? h(
|
|
430
476
|
"div",
|
|
431
|
-
{ style: {
|
|
477
|
+
{ style: { marginTop: 12 } },
|
|
432
478
|
h(
|
|
433
479
|
"div",
|
|
434
|
-
{ style:
|
|
435
|
-
h(UrlRow, { label: t("
|
|
436
|
-
h(
|
|
480
|
+
{ style: styles.row },
|
|
481
|
+
h(UrlRow, { label: t("entryUrl"), url: openUrl, t }),
|
|
482
|
+
h("button", { style: styles.btn, onClick: () => setShowQr((value) => !value) }, showQr ? t("hideQr") : t("showQr"))
|
|
437
483
|
),
|
|
438
|
-
|
|
484
|
+
showQr ? h(
|
|
439
485
|
"div",
|
|
440
|
-
|
|
486
|
+
{ style: { marginTop: 12 } },
|
|
441
487
|
h("div", { style: styles.label }, t("qr")),
|
|
442
|
-
h("img", { src:
|
|
488
|
+
entryQr ? h("img", { src: entryQr, alt: t("qr"), style: styles.qr }) : h("div", { style: styles.muted }, t("qrLoading"))
|
|
443
489
|
) : null
|
|
444
490
|
) : null
|
|
445
491
|
)
|
package/client/index.js
CHANGED
|
@@ -64,8 +64,8 @@ function BridgePanel() {
|
|
|
64
64
|
const [error, setError] = React.useState('');
|
|
65
65
|
const [notice, setNotice] = React.useState('');
|
|
66
66
|
const [confirmUnbind, setConfirmUnbind] = React.useState(false);
|
|
67
|
-
const [
|
|
68
|
-
const [
|
|
67
|
+
const [showQr, setShowQr] = React.useState(false);
|
|
68
|
+
const [entryQr, setEntryQr] = React.useState('');
|
|
69
69
|
const [passwordDraft, setPasswordDraft] = React.useState('');
|
|
70
70
|
|
|
71
71
|
const loadAdminToken = React.useCallback(async (port) => {
|
|
@@ -96,19 +96,17 @@ function BridgePanel() {
|
|
|
96
96
|
}, [refresh]);
|
|
97
97
|
|
|
98
98
|
React.useEffect(() => {
|
|
99
|
-
if (!adminToken || !status?.
|
|
100
|
-
|
|
101
|
-
setQrDataUrl('');
|
|
99
|
+
if (!adminToken || !showQr || !status?.openUrl) {
|
|
100
|
+
setEntryQr('');
|
|
102
101
|
return;
|
|
103
102
|
}
|
|
104
103
|
let cancelled = false;
|
|
105
|
-
rpc(RPC_ENDPOINTS.
|
|
104
|
+
rpc(RPC_ENDPOINTS.getEntryQr).then((res) => {
|
|
106
105
|
if (cancelled || !res?.ok) return;
|
|
107
|
-
|
|
108
|
-
setQrDataUrl(res.value?.qrDataUrl ?? '');
|
|
106
|
+
setEntryQr(res.value?.qrDataUrl ?? '');
|
|
109
107
|
}).catch(() => {});
|
|
110
108
|
return () => { cancelled = true; };
|
|
111
|
-
}, [adminToken, status?.
|
|
109
|
+
}, [adminToken, showQr, status?.openUrl, rpc]);
|
|
112
110
|
|
|
113
111
|
const run = React.useCallback(async (key, fn) => {
|
|
114
112
|
setBusy(key);
|
|
@@ -129,6 +127,21 @@ function BridgePanel() {
|
|
|
129
127
|
|
|
130
128
|
const saveGateway = () => run('gateway', () => rpc(RPC_ENDPOINTS.saveGateway, { gatewayUrl: gatewayDraft })).then(() => setNotice(t('saved')));
|
|
131
129
|
const requestCode = () => run('code', () => rpc(RPC_ENDPOINTS.requestDeviceCode));
|
|
130
|
+
const startLogin = () => {
|
|
131
|
+
// 先同步开一个空白窗口,避免 RPC 返回后 await 导致浏览器拦截弹窗;
|
|
132
|
+
// 拿到 verificationUrl 再导航,失败时回退为普通 window.open。
|
|
133
|
+
let popup = null;
|
|
134
|
+
try { popup = window.open('about:blank', '_blank'); } catch {}
|
|
135
|
+
return run('login', () => rpc(RPC_ENDPOINTS.startLogin)).then((value) => {
|
|
136
|
+
if (!value?.verificationUrl) { try { popup?.close(); } catch {} return; }
|
|
137
|
+
try {
|
|
138
|
+
if (popup && !popup.closed) popup.location.href = value.verificationUrl;
|
|
139
|
+
else window.open(value.verificationUrl, '_blank', 'noopener,noreferrer');
|
|
140
|
+
} catch {
|
|
141
|
+
try { window.open(value.verificationUrl, '_blank', 'noopener,noreferrer'); } catch {}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
};
|
|
132
145
|
const bindCode = () => run('bind', () => rpc(RPC_ENDPOINTS.bindDevice, { code: codeDraft })).then((value) => { if (value) setCodeDraft(''); });
|
|
133
146
|
const startTunnel = () => run('start', () => rpc(RPC_ENDPOINTS.startTunnel, { autoStart: true }));
|
|
134
147
|
const stopTunnel = () => run('stop', () => rpc(RPC_ENDPOINTS.stopTunnel));
|
|
@@ -142,6 +155,7 @@ function BridgePanel() {
|
|
|
142
155
|
const phase = status?.phase ?? 'idle';
|
|
143
156
|
const publicUrl = status?.publicUrl ?? '';
|
|
144
157
|
const pairing = status?.pairing;
|
|
158
|
+
const openUrl = status?.openUrl ?? '';
|
|
145
159
|
const passwordSet = Boolean(status?.access?.passwordSet);
|
|
146
160
|
const stateText = connected ? t('ready') : (phase === 'connecting' || phase === 'reconnecting' ? t('connecting') : t('offline'));
|
|
147
161
|
|
|
@@ -184,7 +198,7 @@ function BridgePanel() {
|
|
|
184
198
|
: t('notBound'),
|
|
185
199
|
),
|
|
186
200
|
),
|
|
187
|
-
!bound && h('button', { style: styles.btnPrimary, disabled: Boolean(busy), onClick:
|
|
201
|
+
!bound && h('button', { style: styles.btnPrimary, disabled: Boolean(busy), onClick: startLogin }, t('loginNow')),
|
|
188
202
|
bound && h('button', {
|
|
189
203
|
style: styles.btn,
|
|
190
204
|
disabled: Boolean(busy),
|
|
@@ -192,18 +206,34 @@ function BridgePanel() {
|
|
|
192
206
|
}, t('unbind')),
|
|
193
207
|
),
|
|
194
208
|
pairing && pairing.status === 'pending' ? h('div', { style: { marginTop: 12 } },
|
|
195
|
-
h('div', { style: styles.muted }, t('
|
|
196
|
-
h('
|
|
197
|
-
h('div', { style: styles.muted }, t('codeExpires')),
|
|
198
|
-
pairing.verificationUrl ? h('a', { href: pairing.verificationUrl, target: '_blank', rel: 'noreferrer', style: { fontSize: 12 } }, t('openConsole')) : null,
|
|
209
|
+
h('div', { style: styles.muted }, t('loginWaiting')),
|
|
210
|
+
pairing.verificationUrl ? h('a', { href: pairing.verificationUrl, target: '_blank', rel: 'noreferrer', style: { fontSize: 12 } }, t('reopenAuthorize')) : null,
|
|
199
211
|
) : null,
|
|
200
|
-
|
|
201
|
-
h('
|
|
202
|
-
h('div', { style:
|
|
203
|
-
h('
|
|
204
|
-
h('
|
|
212
|
+
h('details', { style: { marginTop: 12 } },
|
|
213
|
+
h('summary', { style: { ...styles.muted, cursor: 'pointer' } }, t('advanced')),
|
|
214
|
+
!bound ? h('div', { style: { marginTop: 10 } },
|
|
215
|
+
h('span', { style: styles.label }, t('bindManual')),
|
|
216
|
+
h('div', { style: styles.row },
|
|
217
|
+
h('input', { style: styles.input, value: codeDraft, onChange: (e) => setCodeDraft(e.target.value), placeholder: 'ABCD-1234' }),
|
|
218
|
+
h('button', { style: styles.btn, disabled: busy === 'bind' || !codeDraft, onClick: bindCode }, t('bind')),
|
|
219
|
+
h('button', { style: styles.btn, disabled: Boolean(busy), onClick: requestCode }, t('getCode')),
|
|
220
|
+
),
|
|
221
|
+
) : null,
|
|
222
|
+
h('div', { style: { marginTop: 12 } },
|
|
223
|
+
h('div', { style: { fontSize: 13, fontWeight: 600 } }, t('password')),
|
|
224
|
+
h('div', { style: styles.muted }, passwordSet ? t('passwordSet') : t('passwordHint')),
|
|
225
|
+
h('div', { style: { ...styles.row, marginTop: 8 } },
|
|
226
|
+
h('input', {
|
|
227
|
+
style: styles.input,
|
|
228
|
+
type: 'password',
|
|
229
|
+
value: passwordDraft,
|
|
230
|
+
onChange: (e) => setPasswordDraft(e.target.value),
|
|
231
|
+
placeholder: t('passwordPlaceholder'),
|
|
232
|
+
}),
|
|
233
|
+
h('button', { style: styles.btn, disabled: busy === 'password' || passwordDraft.length < 6, onClick: savePassword }, t('savePassword')),
|
|
234
|
+
),
|
|
205
235
|
),
|
|
206
|
-
)
|
|
236
|
+
),
|
|
207
237
|
confirmUnbind ? h('div', { style: { marginTop: 12 } },
|
|
208
238
|
h('div', { style: { ...styles.muted, marginBottom: 8 } }, t('unbindConfirm')),
|
|
209
239
|
h('div', { style: styles.row },
|
|
@@ -213,21 +243,6 @@ function BridgePanel() {
|
|
|
213
243
|
) : null,
|
|
214
244
|
),
|
|
215
245
|
|
|
216
|
-
h('div', { style: styles.card },
|
|
217
|
-
h('div', { style: { fontSize: 13, fontWeight: 600 } }, t('password')),
|
|
218
|
-
h('div', { style: styles.muted }, passwordSet ? t('passwordSet') : t('passwordHint')),
|
|
219
|
-
h('div', { style: { ...styles.row, marginTop: 8 } },
|
|
220
|
-
h('input', {
|
|
221
|
-
style: styles.input,
|
|
222
|
-
type: 'password',
|
|
223
|
-
value: passwordDraft,
|
|
224
|
-
onChange: (e) => setPasswordDraft(e.target.value),
|
|
225
|
-
placeholder: t('passwordPlaceholder'),
|
|
226
|
-
}),
|
|
227
|
-
h('button', { style: styles.btnPrimary, disabled: busy === 'password' || passwordDraft.length < 6, onClick: savePassword }, t('savePassword')),
|
|
228
|
-
),
|
|
229
|
-
),
|
|
230
|
-
|
|
231
246
|
h('div', { style: styles.card },
|
|
232
247
|
h('div', { style: styles.row },
|
|
233
248
|
h('div', { style: { flex: 1, minWidth: 200 } },
|
|
@@ -236,16 +251,16 @@ function BridgePanel() {
|
|
|
236
251
|
),
|
|
237
252
|
connected
|
|
238
253
|
? h('button', { style: styles.btn, disabled: Boolean(busy), onClick: stopTunnel }, t('stop'))
|
|
239
|
-
: h('button', { style: styles.btnPrimary, disabled: Boolean(busy) || !bound
|
|
254
|
+
: h('button', { style: styles.btnPrimary, disabled: Boolean(busy) || !bound, onClick: startTunnel }, t('start')),
|
|
240
255
|
),
|
|
241
|
-
|
|
242
|
-
h('div', { style:
|
|
243
|
-
h(UrlRow, { label: t('
|
|
244
|
-
h(
|
|
256
|
+
openUrl ? h('div', { style: { marginTop: 12 } },
|
|
257
|
+
h('div', { style: styles.row },
|
|
258
|
+
h(UrlRow, { label: t('entryUrl'), url: openUrl, t }),
|
|
259
|
+
h('button', { style: styles.btn, onClick: () => setShowQr((value) => !value) }, showQr ? t('hideQr') : t('showQr')),
|
|
245
260
|
),
|
|
246
|
-
|
|
261
|
+
showQr ? h('div', { style: { marginTop: 12 } },
|
|
247
262
|
h('div', { style: styles.label }, t('qr')),
|
|
248
|
-
h('img', { src:
|
|
263
|
+
entryQr ? h('img', { src: entryQr, alt: t('qr'), style: styles.qr }) : h('div', { style: styles.muted }, t('qrLoading')),
|
|
249
264
|
) : null,
|
|
250
265
|
) : null,
|
|
251
266
|
),
|
package/client/locales.mjs
CHANGED
|
@@ -9,6 +9,14 @@ export const zh = {
|
|
|
9
9
|
notBound: '未绑定',
|
|
10
10
|
bound: '已绑定',
|
|
11
11
|
getCode: '获取设备码',
|
|
12
|
+
loginNow: '立即登录授权',
|
|
13
|
+
loginWaiting: '已打开浏览器授权页,请在浏览器中完成登录并点「授权此设备」;授权后本页会自动刷新。',
|
|
14
|
+
reopenAuthorize: '重新打开授权页',
|
|
15
|
+
advanced: '高级:手动设备码 / 局域网口令(可选)',
|
|
16
|
+
entryUrl: '扫码入口(SaaS 登录后进入)',
|
|
17
|
+
showQr: '显示二维码',
|
|
18
|
+
hideQr: '收起二维码',
|
|
19
|
+
qrLoading: '二维码生成中…',
|
|
12
20
|
codeTitle: '在控制台添加设备并输入此设备码',
|
|
13
21
|
codeExpires: '设备码 10 分钟内有效,绑定后自动消失。',
|
|
14
22
|
openConsole: '打开控制台',
|
|
@@ -30,7 +38,7 @@ export const zh = {
|
|
|
30
38
|
qr: '扫码打开',
|
|
31
39
|
lastError: '最近错误',
|
|
32
40
|
password: '访问口令',
|
|
33
|
-
passwordHint: '
|
|
41
|
+
passwordHint: '可选:为局域网直连设置本地口令;SaaS 隧道访客走网关签名,无需口令。',
|
|
34
42
|
passwordSet: '已设置访问口令(可在此修改)。',
|
|
35
43
|
passwordPlaceholder: '至少 6 位',
|
|
36
44
|
savePassword: '保存口令',
|
|
@@ -52,6 +60,14 @@ export const en = {
|
|
|
52
60
|
notBound: 'Not bound',
|
|
53
61
|
bound: 'Bound',
|
|
54
62
|
getCode: 'Get device code',
|
|
63
|
+
loginNow: 'Log in & authorize',
|
|
64
|
+
loginWaiting: 'The authorization page is open in your browser. Sign in and click “Authorize this device”; this panel will refresh automatically.',
|
|
65
|
+
reopenAuthorize: 'Reopen authorization page',
|
|
66
|
+
advanced: 'Advanced: manual device code / LAN password (optional)',
|
|
67
|
+
entryUrl: 'Scan entry (sign in to SaaS first)',
|
|
68
|
+
showQr: 'Show QR code',
|
|
69
|
+
hideQr: 'Hide QR code',
|
|
70
|
+
qrLoading: 'Generating QR code…',
|
|
55
71
|
codeTitle: 'Add the device in the console and enter this code',
|
|
56
72
|
codeExpires: 'The code is valid for 10 minutes and disappears after binding.',
|
|
57
73
|
openConsole: 'Open console',
|
|
@@ -73,7 +89,7 @@ export const en = {
|
|
|
73
89
|
qr: 'Scan to open',
|
|
74
90
|
lastError: 'Last error',
|
|
75
91
|
password: 'Access password',
|
|
76
|
-
passwordHint: '
|
|
92
|
+
passwordHint: 'Optional: set a local password for LAN access. SaaS tunnel visitors are authenticated by the gateway signature and do not need it.',
|
|
77
93
|
passwordSet: 'Access password is set (you can change it here).',
|
|
78
94
|
passwordPlaceholder: 'At least 6 characters',
|
|
79
95
|
savePassword: 'Save password',
|
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
|
@@ -19,6 +19,8 @@ import { PLUGIN_ID } from './identity.js';
|
|
|
19
19
|
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
|
+
export const GATE_HEADER = 'x-qzsy-gate';
|
|
23
|
+
const GATE_MAX_SKEW_SECONDS = 60;
|
|
22
24
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
25
|
const LOGIN_MAX_FAILURES = 5;
|
|
24
26
|
const LOGIN_LOCK_MS = 60 * 1000;
|
|
@@ -58,7 +60,7 @@ function cleanHeaders(headers, { cookie, targetPort } = {}) {
|
|
|
58
60
|
const out = {};
|
|
59
61
|
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
60
62
|
const lower = name.toLowerCase();
|
|
61
|
-
if (HOP_BY_HOP.has(lower) || lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER) continue;
|
|
63
|
+
if (HOP_BY_HOP.has(lower) || lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER || lower === GATE_HEADER) continue;
|
|
62
64
|
if (value === undefined) continue;
|
|
63
65
|
out[lower] = value;
|
|
64
66
|
}
|
|
@@ -89,6 +91,7 @@ export class GateProxy {
|
|
|
89
91
|
this.host = host;
|
|
90
92
|
this.config = config;
|
|
91
93
|
this.internalSecret = String(internalSecret ?? '');
|
|
94
|
+
this.gateSecret = '';
|
|
92
95
|
this.logger = logger;
|
|
93
96
|
this.dshHome = dshHome;
|
|
94
97
|
this.now = now;
|
|
@@ -114,6 +117,30 @@ export class GateProxy {
|
|
|
114
117
|
return safeEqual(parts[2], this.sessionToken(expiresAt).split('.')[2]);
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
/** 网关信任密钥(随每条控制连接随机下发;断开即失效)。 */
|
|
121
|
+
setGateSecret(secret) {
|
|
122
|
+
this.gateSecret = /^[0-9a-f]{64}$/i.test(String(secret ?? '')) ? String(secret).toLowerCase() : '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 校验网关注入的 x-qzsy-gate:ts.uid.hmac(ts 秒,hmac=HMAC-SHA256(secret, ts.uid) hex)。
|
|
127
|
+
* 允许 ±60 秒漂移;任何格式/签名/时效问题都返回 false。
|
|
128
|
+
*/
|
|
129
|
+
verifyGateTrust(rawValue, nowMs = this.now()) {
|
|
130
|
+
if (!this.gateSecret) return false;
|
|
131
|
+
const parts = String(rawValue ?? '').split('.');
|
|
132
|
+
if (parts.length !== 3) return false;
|
|
133
|
+
const [rawTs, uid, signature] = parts;
|
|
134
|
+
const ts = Number(rawTs);
|
|
135
|
+
if (!Number.isInteger(ts) || uid.length === 0) return false;
|
|
136
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
137
|
+
if (Math.abs(nowSeconds - ts) > GATE_MAX_SKEW_SECONDS) return false;
|
|
138
|
+
const expected = createHmac('sha256', this.gateSecret)
|
|
139
|
+
.update(`${ts}.${uid}`)
|
|
140
|
+
.digest('hex');
|
|
141
|
+
return safeEqual(String(signature).toLowerCase(), expected);
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
/** 来源分类:返回 { source: 'loopback'|'tunnel'|'remote', authenticated, saasUser }。 */
|
|
118
145
|
classify(req) {
|
|
119
146
|
const headers = req?.headers ?? {};
|
|
@@ -121,11 +148,14 @@ export class GateProxy {
|
|
|
121
148
|
if (this.internalSecret && safeEqual(marker, this.internalSecret)) {
|
|
122
149
|
// 内部标记只证明"请求来自本插件的隧道客户端",不等于访客已认证:
|
|
123
150
|
// 仍需本地口令会话 / ?auth= 令牌换来的会话 cookie。
|
|
124
|
-
const
|
|
151
|
+
const gateTrust = this.verifyGateTrust(headers[GATE_HEADER]);
|
|
152
|
+
const gateUid = gateTrust ? String(headers[GATE_HEADER]).split('.')[1] : '';
|
|
125
153
|
return {
|
|
126
154
|
source: 'tunnel',
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
// 契约 §3:隧道来源只认网关签名;未验签一律 401(本地口令会话也不再作为隧道回退)。
|
|
156
|
+
authenticated: gateTrust,
|
|
157
|
+
gateTrust,
|
|
158
|
+
saasUser: gateUid || String(headers[SAAS_USER_HEADER] ?? ''),
|
|
129
159
|
};
|
|
130
160
|
}
|
|
131
161
|
const address = req?.socket?.remoteAddress;
|
|
@@ -171,6 +201,13 @@ export class GateProxy {
|
|
|
171
201
|
<div><h1>尚未设置访问口令</h1><p>DSH 远程隧道云默认拒绝未认证的远程/隧道访客。</p><p>请先在<b>本机</b> DSH 设置页「远程访问」中设置访问口令,再把「带访问令牌的链接」发给需要访问的人。</p></div></html>`;
|
|
172
202
|
}
|
|
173
203
|
|
|
204
|
+
_gateRejectedPage() {
|
|
205
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8" />
|
|
206
|
+
<title>DSH 远程隧道云 · 未授权</title>
|
|
207
|
+
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e5e7eb;display:grid;place-items:center;min-height:100vh;margin:0}div{background:#171a21;padding:28px;border-radius:12px;width:min(92vw,440px);border:1px solid #2b303b}h1{font-size:17px;margin:0 0 12px}p{color:#9ca3af;font-size:13px;line-height:1.7;margin:6px 0}</style>
|
|
208
|
+
<div><h1>未获得云隧道授权</h1><p>该请求缺少有效的网关签名。请回到 DSH 面板,点「立即登录授权」并完成浏览器授权,或重新扫码进入。</p></div></html>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
174
211
|
_loginBlocked(address) {
|
|
175
212
|
const entry = this.loginFailures.get(address);
|
|
176
213
|
if (!entry) return false;
|
|
@@ -318,7 +355,7 @@ export class GateProxy {
|
|
|
318
355
|
const headers = {};
|
|
319
356
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
320
357
|
const lower = name.toLowerCase();
|
|
321
|
-
if (lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER) continue;
|
|
358
|
+
if (lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER || lower === GATE_HEADER) continue;
|
|
322
359
|
if (value !== undefined) headers[lower] = value;
|
|
323
360
|
}
|
|
324
361
|
headers.host = `127.0.0.1:${this.targetPort}`;
|
|
@@ -358,6 +395,17 @@ export class GateProxy {
|
|
|
358
395
|
if (!verdict.authenticated) {
|
|
359
396
|
const wantsJson = String(req.headers.accept ?? '').includes('application/json')
|
|
360
397
|
|| String(req.url ?? '').startsWith('/api/');
|
|
398
|
+
// 隧道来源只认网关签名,不再降级到本地口令;未验签一律 401。
|
|
399
|
+
if (verdict.source === 'tunnel') {
|
|
400
|
+
if (wantsJson) {
|
|
401
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
402
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized', code: 'gate_trust_required' }));
|
|
403
|
+
} else {
|
|
404
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
405
|
+
res.end(this._gateRejectedPage());
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
361
409
|
const passwordSet = this.passwordConfigured();
|
|
362
410
|
if (wantsJson) {
|
|
363
411
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
package/lib/identity.js
CHANGED
|
@@ -18,6 +18,8 @@ export const RPC_ENDPOINTS = Object.freeze({
|
|
|
18
18
|
startTunnel: 'startTunnel',
|
|
19
19
|
stopTunnel: 'stopTunnel',
|
|
20
20
|
getAccessUrl: 'getAccessUrl',
|
|
21
|
+
startLogin: 'startLogin',
|
|
22
|
+
getEntryQr: 'getEntryQr',
|
|
21
23
|
setAccessPassword: 'setAccessPassword',
|
|
22
24
|
clearAccessPassword: 'clearAccessPassword',
|
|
23
25
|
});
|
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
|
}
|
|
@@ -124,6 +117,14 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
124
117
|
};
|
|
125
118
|
|
|
126
119
|
const gatewayBase = () => normalizeSaasBaseUrl(config.gatewayUrl);
|
|
120
|
+
const webBase = () => gatewayBase();
|
|
121
|
+
const currentPublicId = () => config.tunnel?.publicId || tunnel?.tunnel?.publicId || '';
|
|
122
|
+
const openUrl = () => {
|
|
123
|
+
const publicId = currentPublicId();
|
|
124
|
+
return publicId ? `${webBase()}/open/${encodeURIComponent(publicId)}` : '';
|
|
125
|
+
};
|
|
126
|
+
const authorizeUrl = (code) => `${webBase()}/device/authorize?code=${encodeURIComponent(code)}`;
|
|
127
|
+
const pairingPollMs = Math.max(10, Number(pluginConfig.pairingPollMs) || PAIRING_POLL_MS);
|
|
127
128
|
|
|
128
129
|
const createTunnel = () => new SaasTunnelClient({
|
|
129
130
|
gatewayUrl: gatewayBase(),
|
|
@@ -145,7 +146,12 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
145
146
|
}),
|
|
146
147
|
onHelloAck: (ack) => {
|
|
147
148
|
lastError = null;
|
|
148
|
-
|
|
149
|
+
// 网关为每条连接随机下发信任密钥:gate 用它验 x-qzsy-gate,验签通过即免本地口令。
|
|
150
|
+
gate.setGateSecret(ack?.gate?.secret ?? '');
|
|
151
|
+
if (ack?.tunnel?.publicId && (config.tunnel?.publicId !== ack.tunnel.publicId || config.tunnel?.name !== ack.tunnel.name)) {
|
|
152
|
+
persist({ tunnel: { publicId: String(ack.tunnel.publicId), name: String(ack.tunnel.name ?? '') } });
|
|
153
|
+
}
|
|
154
|
+
logger?.info?.(`SaaS tunnel ready (publicId=%s, gateTrust=%s)`, ack?.tunnel?.publicId ?? '', Boolean(gate.gateSecret));
|
|
149
155
|
},
|
|
150
156
|
onRouteUpdate: ({ routes, rev }) => {
|
|
151
157
|
persist({ routesVersion: rev });
|
|
@@ -154,7 +160,10 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
154
160
|
onDrain: () => logger?.warn?.('SaaS tunnel draining by gateway'),
|
|
155
161
|
onDrainComplete: () => { lastError = 'SaaS 网关已下线(drain)'; },
|
|
156
162
|
onStateChange: ({ state, detail }) => {
|
|
157
|
-
if (state === 'error'
|
|
163
|
+
if (state === 'error' || state === 'idle' || state === 'closed' || state === 'reconnecting') {
|
|
164
|
+
gate.setGateSecret('');
|
|
165
|
+
if (state === 'error') lastError = detail;
|
|
166
|
+
}
|
|
158
167
|
},
|
|
159
168
|
logger,
|
|
160
169
|
});
|
|
@@ -195,7 +204,13 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
195
204
|
routesVersion: 0,
|
|
196
205
|
});
|
|
197
206
|
clearPairing();
|
|
198
|
-
|
|
207
|
+
// 无感授权:绑定成功即自动开隧道(面板随即显示二维码),无需再点一次。
|
|
208
|
+
if (pairing.autoStart !== false) {
|
|
209
|
+
persist({ autoStart: true });
|
|
210
|
+
await ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
|
|
211
|
+
} else if (config.autoStart) {
|
|
212
|
+
await ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
|
|
213
|
+
}
|
|
199
214
|
} else if (info.status === 'expired' || info.status === 'consumed') {
|
|
200
215
|
pairing.status = info.status;
|
|
201
216
|
clearPairing();
|
|
@@ -203,7 +218,7 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
203
218
|
} catch (err) {
|
|
204
219
|
lastError = err?.message ?? String(err);
|
|
205
220
|
}
|
|
206
|
-
},
|
|
221
|
+
}, pairingPollMs);
|
|
207
222
|
pairingTimer.unref?.();
|
|
208
223
|
};
|
|
209
224
|
|
|
@@ -225,6 +240,7 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
225
240
|
tunnel: config.tunnel ?? null,
|
|
226
241
|
},
|
|
227
242
|
publicUrl,
|
|
243
|
+
openUrl: openUrl(),
|
|
228
244
|
routes: Array.isArray(tunnel?.routes) ? tunnel.routes : [],
|
|
229
245
|
lastError: lastError || tunnel?.lastError || '',
|
|
230
246
|
proxy: { port: gatePort, host: '127.0.0.1', url: `http://127.0.0.1:${gatePort}` },
|
|
@@ -269,8 +285,9 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
269
285
|
code: created.code,
|
|
270
286
|
expiresAt: created.expiresAt ?? null,
|
|
271
287
|
expiresAtMs: created.expiresAtMs ?? null,
|
|
272
|
-
verificationUrl: created.verificationUrl
|
|
288
|
+
verificationUrl: created.verificationUrl || authorizeUrl(created.code),
|
|
273
289
|
status: 'pending',
|
|
290
|
+
autoStart: false,
|
|
274
291
|
};
|
|
275
292
|
lastError = null;
|
|
276
293
|
startPairingPoll();
|
|
@@ -295,13 +312,46 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
295
312
|
return ok({ deviceId: config.deviceId, tunnel: config.tunnel });
|
|
296
313
|
}
|
|
297
314
|
case RPC_ENDPOINTS.unbindDevice:
|
|
315
|
+
gate.setGateSecret('');
|
|
298
316
|
await stopTunnel();
|
|
299
317
|
clearPairing();
|
|
300
318
|
pairing = null;
|
|
301
319
|
persist({ deviceId: '', deviceSecret: '', tunnel: null, routesVersion: 0, autoStart: false });
|
|
302
320
|
return ok({ unbound: true });
|
|
321
|
+
case RPC_ENDPOINTS.startLogin: {
|
|
322
|
+
const created = await requestDeviceCode({
|
|
323
|
+
gatewayUrl: gatewayBase(),
|
|
324
|
+
deviceInfo: buildDeviceInfo({
|
|
325
|
+
name: hostname(),
|
|
326
|
+
platform: osPlatform(),
|
|
327
|
+
hostname: hostname(),
|
|
328
|
+
arch: osArch(),
|
|
329
|
+
pluginVersion: VERSION,
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
const verificationUrl = authorizeUrl(created.code);
|
|
333
|
+
pairing = {
|
|
334
|
+
code: created.code,
|
|
335
|
+
expiresAt: created.expiresAt ?? null,
|
|
336
|
+
expiresAtMs: created.expiresAtMs ?? null,
|
|
337
|
+
verificationUrl,
|
|
338
|
+
status: 'pending',
|
|
339
|
+
autoStart: true,
|
|
340
|
+
};
|
|
341
|
+
lastError = null;
|
|
342
|
+
startPairingPoll();
|
|
343
|
+
return ok({ code: created.code, verificationUrl, expiresAt: created.expiresAt ?? null });
|
|
344
|
+
}
|
|
345
|
+
case RPC_ENDPOINTS.getEntryQr: {
|
|
346
|
+
const url = openUrl();
|
|
347
|
+
if (!url) return fail('bad-request', '尚未绑定设备或尚未获得公网隧道');
|
|
348
|
+
let qrDataUrl = null;
|
|
349
|
+
try {
|
|
350
|
+
qrDataUrl = await QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#1F2421', light: '#FFFFFF' } });
|
|
351
|
+
} catch { qrDataUrl = null; }
|
|
352
|
+
return ok({ openUrl: url, qrDataUrl });
|
|
353
|
+
}
|
|
303
354
|
case RPC_ENDPOINTS.startTunnel:
|
|
304
|
-
if (!gate.passwordConfigured()) return fail('password_not_set', '请先设置访问密码:远程访客需要凭此口令访问');
|
|
305
355
|
persist({ autoStart: payload?.autoStart !== false });
|
|
306
356
|
await ensureConnected();
|
|
307
357
|
return ok(await status());
|
|
@@ -342,7 +392,7 @@ export async function apply(ctx, pluginConfig = {}) {
|
|
|
342
392
|
|
|
343
393
|
const disposeRpc = registerRpc(ctx, rpcHandler, logger);
|
|
344
394
|
|
|
345
|
-
if (config.deviceSecret && config.autoStart
|
|
395
|
+
if (config.deviceSecret && config.autoStart) {
|
|
346
396
|
ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
|
|
347
397
|
}
|
|
348
398
|
|
|
@@ -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;
|
|
@@ -150,6 +149,7 @@ export class SaasTunnelClient {
|
|
|
150
149
|
this.limits = null;
|
|
151
150
|
this.features = null;
|
|
152
151
|
this.serverTime = null;
|
|
152
|
+
this.gateSecret = '';
|
|
153
153
|
this._inFlight = new Set();
|
|
154
154
|
this._localWs = new Map();
|
|
155
155
|
this._bytesIn = 0;
|
|
@@ -378,6 +378,10 @@ export class SaasTunnelClient {
|
|
|
378
378
|
if (msg.tunnel) this.tunnel = { publicId: String(msg.tunnel.publicId ?? ''), name: String(msg.tunnel.name ?? '') };
|
|
379
379
|
this.limits = msg.limits ?? null;
|
|
380
380
|
this.features = msg.features ?? null;
|
|
381
|
+
// 网关为每条控制连接随机下发信任密钥;gate 用它验 x-qzsy-gate 的 HMAC。
|
|
382
|
+
this.gateSecret = typeof msg.gate?.secret === 'string' && /^[0-9a-f]{64}$/i.test(msg.gate.secret)
|
|
383
|
+
? msg.gate.secret.toLowerCase()
|
|
384
|
+
: '';
|
|
381
385
|
this.serverTime = msg.serverTime ?? null;
|
|
382
386
|
if (this.limits?.heartbeatMs) this.heartbeatIntervalMs = Math.max(10, Number(this.limits.heartbeatMs) || this.heartbeatIntervalMs);
|
|
383
387
|
if (this.limits?.maxResponseBytes) this.maxResponseBytes = Math.max(1024, Number(this.limits.maxResponseBytes) || this.maxResponseBytes);
|
|
@@ -425,6 +429,7 @@ export class SaasTunnelClient {
|
|
|
425
429
|
_finishDrain() {
|
|
426
430
|
if (!this._draining) return;
|
|
427
431
|
this._draining = false;
|
|
432
|
+
this.gateSecret = '';
|
|
428
433
|
this._suppressReconnect = true;
|
|
429
434
|
if (this._drainTimer) { clearTimeout(this._drainTimer); this._drainTimer = null; }
|
|
430
435
|
this._connected = false;
|
|
@@ -687,6 +692,7 @@ export class SaasTunnelClient {
|
|
|
687
692
|
|
|
688
693
|
_onClose(code, reason) {
|
|
689
694
|
this._connected = false;
|
|
695
|
+
this.gateSecret = '';
|
|
690
696
|
this._stopHeartbeat();
|
|
691
697
|
this._cleanupLocalWs();
|
|
692
698
|
this._inFlight.clear();
|
|
@@ -743,6 +749,7 @@ export class SaasTunnelClient {
|
|
|
743
749
|
|
|
744
750
|
async disconnect() {
|
|
745
751
|
this._manuallyClosed = true;
|
|
752
|
+
this.gateSecret = '';
|
|
746
753
|
this._draining = false;
|
|
747
754
|
this._connected = false;
|
|
748
755
|
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qzsy/dsh-bridge",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
|
|
5
|
-
"releaseNotes": "【v3.
|
|
5
|
+
"releaseNotes": "【v3.1.0】无感登录授权与扫码进入\\n• 面板新增「立即登录授权」:自动申请设备码、浏览器授权、绑定后自动开隧道,无需输入设备码或设置本地口令\\n• 新增扫码入口二维码:https://<控制面>/open/<publicId>,扫码登录 SaaS 账号即可进入隧道\\n• 网关信任头 x-qzsy-gate(HMAC-SHA256,±60 秒):验签通过即免本地口令,未验签隧道来源一律 401\\n• hello-ack 支持 gate.secret 每连接随机下发;改口令/断线自动清理信任密钥\\n• 本地口令降级为可选「高级」局域网能力",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|