@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 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
- 0. **首次使用**:在面板设置访问口令(远程访客的门禁凭据)。
21
+ 无感授权流程(推荐,全程不需要输入设备码、也不需要设本地口令):
22
+
22
23
  1. 面板中确认 **SaaS 网关地址**:官方云为
23
24
  `wss://dsh.b.nps.qzsyzn.com/_gw/connect`(自建部署填自己的网关地址),点「保存网关」。
24
- 2. 点「获取设备码」,面板显示 8 位设备码(10 分钟内有效)。
25
- 3. 打开 [DSH 远程隧道云控制台](https://dsh.b.nps.qzsyzn.com/user/tunnels) →「添加设备」,
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
- 绑定信息(`deviceId` / `deviceSecret`)只保存在本机
31
- `~/.dsh/qzsy-dsh-bridge/config.json`(权限 0600);面板只显示掩码。解绑会清除本机凭证,
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 隧道 | 携带插件隧道客户端注入的 `x-dsh-internal-tunnel`(进程内随机密钥,公网不可伪造) | 仅代表来源是隧道;访客仍必须通过本地口令 / `?auth=` 会话,否则 401 |
50
- | 局域网 / 其它直连 | 非回环且无有效隧道标记 | 必须通过本地口令登录或 `?auth=` 令牌换取会话,否则 401 |
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
- - **首次使用必须在面板设置访问口令**(至少 6 位);未设置时所有远程/隧道访客都会被拒绝并返回初始化引导页。
54
- - 面板「带访问令牌的链接」包含固定 `?auth=` 令牌,首次打开后换取 30 天会话 cookie;令牌可重复使用,请只发给可信设备,泄露后请在控制台吊销设备并解绑重绑。
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
- | 设备码过期 | 设备码 10 分钟有效,重新「获取设备码」 |
80
+ | 面板显示「未绑定」 | 确认网关地址可访问;重新点「立即登录授权」,确认浏览器里已点「授权此设备」 |
81
+ | 授权页提示设备码无效/过期 | 设备码 10 分钟有效,回面板重新点「立即登录授权」 |
78
82
  | 开启隧道一直「连接中」 | 检查 `wss://.../_gw/connect` 是否可直连(公司代理/防火墙可能拦截 WebSocket) |
79
83
  | 握手失败 `auth_failed` / `revoked` | 设备已吊销或令牌失效:解绑后重新绑定;被吊销需在控制台重新添加设备 |
80
- | 打开公网地址 401 | 先确认已在面板设置访问口令;用「带访问令牌的链接」打开一次换取会话;或确认控制台访问模式为 password |
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.0.0)
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: "\u9996\u6B21\u4F7F\u7528\u5FC5\u987B\u5148\u8BBE\u7F6E\u8BBF\u95EE\u53E3\u4EE4\uFF1B\u8FDC\u7A0B/\u5C40\u57DF\u7F51\u8BBF\u5BA2\u9700\u51ED\u6B64\u53E3\u4EE4\u901A\u8FC7\u672C\u5730\u95E8\u7981\u3002",
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: "Set an access password before first use; remote/LAN visitors must pass the local gate with it.",
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 [accessUrl, setAccessUrl] = React.useState("");
219
- const [qrDataUrl, setQrDataUrl] = React.useState("");
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?.publicUrl) {
252
- setAccessUrl("");
253
- setQrDataUrl("");
269
+ if (!adminToken || !showQr || !status?.openUrl) {
270
+ setEntryQr("");
254
271
  return;
255
272
  }
256
273
  let cancelled = false;
257
- rpc(RPC_ENDPOINTS.getAccessUrl).then((res) => {
274
+ rpc(RPC_ENDPOINTS.getEntryQr).then((res) => {
258
275
  if (cancelled || !res?.ok) return;
259
- setAccessUrl(res.value?.url ?? "");
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?.publicUrl, rpc]);
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: requestCode }, t("getCode")),
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("codeTitle")),
370
- h("div", { style: styles.code }, pairing.code),
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
- !bound ? h(
375
- "div",
414
+ h(
415
+ "details",
376
416
  { style: { marginTop: 12 } },
377
- h("span", { style: styles.label }, t("bindManual")),
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: styles.row },
381
- h("input", { style: styles.input, value: codeDraft, onChange: (e) => setCodeDraft(e.target.value), placeholder: "ABCD-1234" }),
382
- h("button", { style: styles.btn, disabled: busy === "bind" || !codeDraft, onClick: bindCode }, t("bind"))
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
- ) : null,
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 || !passwordSet, onClick: startTunnel }, t("start"))
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
- publicUrl ? h(
475
+ openUrl ? h(
430
476
  "div",
431
- { style: { display: "flex", gap: 16, marginTop: 12, flexWrap: "wrap", alignItems: "flex-start" } },
477
+ { style: { marginTop: 12 } },
432
478
  h(
433
479
  "div",
434
- { style: { flex: "1 1 320px", minWidth: 260 } },
435
- h(UrlRow, { label: t("publicUrl"), url: publicUrl, t }),
436
- h(UrlRow, { label: t("accessUrl"), url: accessUrl, t })
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
- qrDataUrl ? h(
484
+ showQr ? h(
439
485
  "div",
440
- null,
486
+ { style: { marginTop: 12 } },
441
487
  h("div", { style: styles.label }, t("qr")),
442
- h("img", { src: qrDataUrl, alt: t("qr"), style: styles.qr })
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 [accessUrl, setAccessUrl] = React.useState('');
68
- const [qrDataUrl, setQrDataUrl] = React.useState('');
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?.publicUrl) {
100
- setAccessUrl('');
101
- setQrDataUrl('');
99
+ if (!adminToken || !showQr || !status?.openUrl) {
100
+ setEntryQr('');
102
101
  return;
103
102
  }
104
103
  let cancelled = false;
105
- rpc(RPC_ENDPOINTS.getAccessUrl).then((res) => {
104
+ rpc(RPC_ENDPOINTS.getEntryQr).then((res) => {
106
105
  if (cancelled || !res?.ok) return;
107
- setAccessUrl(res.value?.url ?? '');
108
- setQrDataUrl(res.value?.qrDataUrl ?? '');
106
+ setEntryQr(res.value?.qrDataUrl ?? '');
109
107
  }).catch(() => {});
110
108
  return () => { cancelled = true; };
111
- }, [adminToken, status?.publicUrl, rpc]);
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: requestCode }, t('getCode')),
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('codeTitle')),
196
- h('div', { style: styles.code }, pairing.code),
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
- !bound ? h('div', { style: { marginTop: 12 } },
201
- h('span', { style: styles.label }, t('bindManual')),
202
- h('div', { style: styles.row },
203
- h('input', { style: styles.input, value: codeDraft, onChange: (e) => setCodeDraft(e.target.value), placeholder: 'ABCD-1234' }),
204
- h('button', { style: styles.btn, disabled: busy === 'bind' || !codeDraft, onClick: bindCode }, t('bind')),
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
- ) : null,
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 || !passwordSet, onClick: startTunnel }, t('start')),
254
+ : h('button', { style: styles.btnPrimary, disabled: Boolean(busy) || !bound, onClick: startTunnel }, t('start')),
240
255
  ),
241
- publicUrl ? h('div', { style: { display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap', alignItems: 'flex-start' } },
242
- h('div', { style: { flex: '1 1 320px', minWidth: 260 } },
243
- h(UrlRow, { label: t('publicUrl'), url: publicUrl, t }),
244
- h(UrlRow, { label: t('accessUrl'), url: accessUrl, t }),
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
- qrDataUrl ? h('div', null,
261
+ showQr ? h('div', { style: { marginTop: 12 } },
247
262
  h('div', { style: styles.label }, t('qr')),
248
- h('img', { src: qrDataUrl, alt: t('qr'), style: styles.qr }),
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
  ),
@@ -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: 'Set an access password before first use; remote/LAN visitors must pass the local gate with it.',
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 < 65536 ? port : DEFAULT_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: 2592000000 };
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 cookies = parseCookies(headers.cookie);
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
- authenticated: this.verifySession(cookies[AUTH_COOKIE]),
128
- saasUser: String(headers[SAAS_USER_HEADER] ?? ''),
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
- // DSH 远程隧道云 · 官方客户端插件(Host 侧)
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
- const rpc = ctx?.connection?.rpc;
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 = rpc.handle(RPC_CHANNEL, handler, { authority: 'loopback' });
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 = rpc.handle(RPC_CHANNEL, handler);
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
- logger?.info?.(`SaaS tunnel ready (publicId=%s)`, ack?.tunnel?.publicId ?? '');
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') lastError = detail;
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
- if (config.autoStart) await ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
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
- }, PAIRING_POLL_MS);
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 && gate.passwordConfigured()) {
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.1",
3
+ "version": "3.1.0",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.0.0】官方客户端插件首个独立实现版本\n• 全新自研代码:插件骨架、本地门禁代理、SaaS 隧道客户端与设置面板均独立实现\n• 设备码绑定 + 短期设备令牌 + v2 控制通道(心跳/路由热更新/drain/终态吊销)\n• 本地门禁:回环免认证、隧道来源用进程内随机密钥标记、局域网访问需本地口令\n• 支持 HTTP / SSE 流式 / WebSocket 转发,公网地址复制与二维码\n• 数据目录 ~/.dsh/qzsy-dsh-bridge,默认本地端口 3092(可配置)",
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": {