@wenbin_wb/dsh-bridge 2.10.1 → 2.10.3

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
@@ -4,6 +4,45 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.10.3] - 2026-09-03
8
+
9
+ ### ✨ 新功能
10
+
11
+ - **兼容 DSH 原生端口直连(3080)**(issue #28):此前本机用 `http://127.0.0.1:3080`(DSH 原生端口)打开面板时,因 loopback-token 端点仅暴露在代理端口 3082、且 CORS 白名单不含原生端口来源,管理功能会被误判为「远程」而锁定(local_only 策略下)。现在原生端口页面可跨域回读管理员令牌,直连与走代理行为一致;CORS 仍收敛,仅回显白名单来源。
12
+
13
+ ### 🐞 修复
14
+
15
+ - **修复代理响应头透传问题**:反向代理与 WebSocket 升级路径现会剥离 hop-by-hop 头(transfer-encoding/connection 等),并对 WS 101 响应做白名单过滤,避免上游注入任意头或客户端解析异常。
16
+ - **修复 QQ 流式消息发送**:首片失败时不再补发「全新流式消息」造成重复,直接降级单条 Markdown;引用机器人消息自动开新会话不再依赖固定 100ms 等待(消除竞态)。
17
+ - **修复微信网关轮询竞态**:stop/restart 时通过 AbortController 立即中断进行中的长轮询,避免新旧 poller 并存触发 iLink 独占锁(403)。
18
+ - **修复飞书群聊审批按钮被误拦**:群聊按「群」整体授权(与 QQ 群模型一致),群内成员可点按钮决议;单聊仍严格校验发起者本人。
19
+ - **会话列表/历史大字段剥离覆盖全部公网入口**:session.list / session.history 的大字段剥离不再仅限自建隧道,Cloudflare 隧道、局域网、外部登记隧道同样生效(社区 PR #27 扩展)。
20
+
21
+ ### 🔒 安全
22
+
23
+ - **[SEND_FILE] 外发白名单**:AI 输出携带的 `[SEND_FILE: 路径]` 指令现仅允许发送会话工作目录内的文件,并拦截 `.ssh`/`.credentials`/`.env` 等敏感路径,防止模型被诱导后外发任意本地文件。
24
+
25
+ ### 🧹 内部优化
26
+
27
+ - **未设密码高危告警**:开启访问认证但尚未设置任何密码时,设置页顶部显示醒目风险提示。
28
+ - **自建隧道安全须知**:README(中/英)明确隧道公网入口无独立认证、必须配合本地访问认证使用。
29
+ - 版本探测失败改为 10 分钟退避重试;版本检查结果 10 分钟 TTL 缓存,减少外网请求。
30
+ - 清理 Telegram 死代码(无调用点的审批卡片方法)。
31
+ - 新增 issue #28 回归测试,全量单测通过。
32
+ ---
33
+
34
+ ## [v2.10.2] - 2026-09-01
35
+
36
+ ### ✨ 新功能
37
+
38
+ - **外部已部署隧道登记**(issue #25):自行部署 cloudflared(Docker 等)的用户可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理
39
+
40
+ ### 🐞 修复
41
+
42
+ - **修复会话列表/历史加载问题**:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)
43
+
44
+ ---
45
+
7
46
  ## [v2.10.1] - 2026-09-01
8
47
 
9
48
  ### 🧹 说明
package/README.en.md CHANGED
@@ -138,6 +138,8 @@ Access DeepSeek Harness from anywhere outside your home network without public I
138
138
  - **Mode 3: Custom WebSocket Tunnel**:
139
139
  - Connect to your personal VPS reverse proxy server ([View Setup Guide](docs/custom-tunnel.md)), equipped with per-message gzip and SSE optimization.
140
140
 
141
+ > **Custom tunnel security note**: The tunnel server (`scripts/install-tunnel-server.sh`) only authenticates the *tunnel client control channel* with `TOKEN`; public HTTP/WebSocket requests forwarded through the tunnel domain are **not independently authenticated** — security relies entirely on the plugin's local Access Auth (the `x-dsh-internal-tunnel` marker prevents tunnel traffic from using the loopback exemption). Always enable **Access Auth** with a password/QR Token in the plugin settings (especially with `scope=all` or when exposed publicly); with no password set, anyone who knows the tunnel URL can reach your DSH.
142
+
141
143
  ---
142
144
 
143
145
  ### 3. 📱 Mobile Experience & Standalone PWA
package/README.md CHANGED
@@ -146,6 +146,8 @@ dsh plugin --profile web add @wenbin_wb/dsh-bridge@latest
146
146
  - **模式 3:自建 WebSocket 隧道**
147
147
  * 支持连接个人 VPS 隧道中转服务器([查看自建隧道部署教程](docs/custom-tunnel.md)),具备数据端到端 gzip 压缩与 SSE 响应优化。
148
148
 
149
+ > **自建隧道安全须知**:隧道服务端(`scripts/install-tunnel-server.sh`)只对「隧道客户端控制通道」校验 `TOKEN`;公网访客对隧道域名的 HTTP/WebSocket 转发**不再做独立认证**,安全完全依赖插件本地的「访问认证」(`x-dsh-internal-tunnel` 标识使隧道流量无法享受本机回环保留)。请务必在插件设置中开启「安全认证」并设置访问密码/二维码 Token(尤其 `scope=all` 或公网使用时);未设置任何密码时,任何知道隧道地址的访客都能直接访问您的 DSH。
150
+
149
151
  ---
150
152
 
151
153
  ### 3. 📱 移动端交互与 PWA 独立全屏 App
package/client/client.js CHANGED
@@ -963,6 +963,7 @@ var BRIDGE_ENDPOINTS = {
963
963
  saveCloudflaredConfig: "saveCloudflaredConfig",
964
964
  setTunnelAutoStart: "setTunnelAutoStart",
965
965
  saveCustomTunnelConfig: "saveCustomTunnelConfig",
966
+ saveExternalTunnel: "saveExternalTunnel",
966
967
  setLanIp: "setLanIp",
967
968
  checkVersion: "checkVersion",
968
969
  upgradePlugin: "upgradePlugin",
@@ -1680,6 +1681,74 @@ var CloudflareConfigForm = React.memo(function CloudflareConfigForm2({ token, ho
1680
1681
  )
1681
1682
  );
1682
1683
  });
1684
+ var ExternalTunnelCard = React.memo(function ExternalTunnelCard2({ ext, onSave }) {
1685
+ const [urlVal, setUrlVal] = React.useState(ext?.url ?? "");
1686
+ const [saving, setSaving] = React.useState(false);
1687
+ const [msg, setMsg] = React.useState(null);
1688
+ React.useEffect(() => {
1689
+ setUrlVal(ext?.url ?? "");
1690
+ }, [ext?.url]);
1691
+ const handleSave = async (e) => {
1692
+ e.preventDefault();
1693
+ setSaving(true);
1694
+ setMsg(null);
1695
+ try {
1696
+ await onSave(urlVal.trim());
1697
+ setMsg({ ok: true, text: urlVal.trim() ? "\u2713 \u5916\u90E8\u96A7\u9053\u5730\u5740\u5DF2\u767B\u8BB0" : "\u2713 \u5DF2\u6E05\u9664\u767B\u8BB0" });
1698
+ } catch (err) {
1699
+ setMsg({ ok: false, text: err.message || "\u4FDD\u5B58\u5931\u8D25" });
1700
+ } finally {
1701
+ setSaving(false);
1702
+ }
1703
+ };
1704
+ return React.createElement(
1705
+ "div",
1706
+ { style: s.card },
1707
+ React.createElement(
1708
+ "div",
1709
+ { style: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10 } },
1710
+ React.createElement(
1711
+ "div",
1712
+ { style: { flex: "1 1 auto", minWidth: 0 } },
1713
+ React.createElement("div", { style: s.label }, "\u5916\u90E8\u5DF2\u90E8\u7F72\u96A7\u9053"),
1714
+ React.createElement(
1715
+ "div",
1716
+ { style: { ...s.muted, marginTop: 2 } },
1717
+ "\u5DF2\u5728 Docker / \u670D\u52A1\u5668\u4E0A\u81EA\u884C\u90E8\u7F72\u96A7\u9053\uFF08\u5982 cloudflared\uFF09\u65F6\uFF0C\u767B\u8BB0\u516C\u7F51\u5730\u5740\u5373\u53EF\u5728\u9762\u677F\u5C55\u793A\u5165\u53E3\u4E0E\u4E8C\u7EF4\u7801\uFF1B\u63D2\u4EF6\u4E0D\u4F1A\u91CD\u590D\u4E0B\u8F7D\u6216\u7BA1\u7406\u8BE5\u96A7\u9053\u3002"
1718
+ )
1719
+ ),
1720
+ React.createElement(StatusTag, { running: Boolean(ext?.configured) })
1721
+ ),
1722
+ ext?.configured && React.createElement(QrBlock, { url: ext?.url, qr: ext?.qr }),
1723
+ React.createElement(
1724
+ "form",
1725
+ { onSubmit: handleSave, style: { marginTop: 10, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
1726
+ React.createElement("input", {
1727
+ style: { ...s.input, flex: "1 1 220px", minWidth: 0 },
1728
+ type: "text",
1729
+ placeholder: "https://tunnel.yourdomain.com \u6216 trycloudflare \u5730\u5740",
1730
+ value: urlVal,
1731
+ onChange: (e) => setUrlVal(e.target.value)
1732
+ }),
1733
+ React.createElement("button", {
1734
+ type: "submit",
1735
+ style: { ...s.btnPri, height: 28, fontSize: 12, padding: "0 12px" },
1736
+ disabled: saving
1737
+ }, saving ? "\u4FDD\u5B58\u4E2D\u2026" : "\u4FDD\u5B58"),
1738
+ ext?.configured && React.createElement("button", {
1739
+ type: "button",
1740
+ style: { ...s.btnGhost, height: 28, fontSize: 12, padding: "0 10px" },
1741
+ onClick: () => {
1742
+ setUrlVal("");
1743
+ onSave("");
1744
+ }
1745
+ }, "\u6E05\u9664"),
1746
+ msg && React.createElement("span", {
1747
+ style: { fontSize: 12, color: msg.ok ? "var(--dsw-alias-state-success-primary, #059669)" : "var(--dsw-alias-state-error-primary, #dc2626)" }
1748
+ }, msg.text)
1749
+ )
1750
+ );
1751
+ });
1683
1752
  var AccessAuthCard = React.memo(function AccessAuthCard2({ auth, rpcCall, onUpdate }) {
1684
1753
  const [enabled, setEnabled] = React.useState(auth?.enabled ?? false);
1685
1754
  const [mode, setMode] = React.useState(auth?.mode ?? "token_and_password");
@@ -1924,7 +1993,31 @@ var AccessAuthCard = React.memo(function AccessAuthCard2({ auth, rpcCall, onUpda
1924
1993
  background: topMsg.ok ? "var(--dsw-alias-state-success-bg,#ecfdf5)" : "var(--dsw-alias-state-error-bg,#fef2f2)",
1925
1994
  color: topMsg.ok ? "var(--dsw-alias-state-success-primary,#059669)" : "var(--dsw-alias-state-error-primary,#dc2626)"
1926
1995
  }
1927
- }, topMsg.text)
1996
+ }, topMsg.text),
1997
+ // 访问认证已开启但尚未设置任何密码/Token 保护:局域网/公网访客可免密进入,高危提示
1998
+ enabled && !auth?.hasPassword && !auth?.hasAdminPassword && React.createElement(
1999
+ "div",
2000
+ {
2001
+ style: {
2002
+ marginTop: 12,
2003
+ padding: "10px 14px",
2004
+ borderRadius: 6,
2005
+ fontSize: 12,
2006
+ background: "var(--dsw-alias-state-error-bg,#fef2f2)",
2007
+ border: "1px solid var(--dsw-alias-state-error-border,#fecaca)",
2008
+ color: "var(--dsw-alias-state-error-primary,#dc2626)",
2009
+ lineHeight: 1.6
2010
+ }
2011
+ },
2012
+ "\u26A0\uFE0F ",
2013
+ React.createElement("strong", null, "\u5C1A\u672A\u8BBE\u7F6E\u4EFB\u4F55\u8BBF\u95EE\u5BC6\u7801\u6216\u7BA1\u7406\u5BC6\u7801"),
2014
+ " \u2014\u2014 \u6B64\u65F6\u201C\u8BBF\u95EE\u8BA4\u8BC1\u5DF2\u5F00\u542F\u201D\u4F46\u4EFB\u4F55\u77E5\u9053\u5C40\u57DF\u7F51 IP / \u96A7\u9053\u5730\u5740\u7684\u4EBA\u90FD\u80FD\u76F4\u63A5\u8FDB\u5165\uFF08\u96A7\u9053\u5165\u53E3\u81EA\u8EAB\u4E0D\u8BBE\u9632\uFF0C\u8BE6\u89C1\u4E0B\u65B9\u5B89\u5168\u987B\u77E5\uFF09\u3002",
2015
+ "\u5EFA\u8BAE\u7ACB\u5373\u5728\u4E0B\u65B9\u8BBE\u7F6E",
2016
+ React.createElement("strong", null, "\u8BBF\u5BA2\u8BBF\u95EE\u5BC6\u7801"),
2017
+ "\u6216",
2018
+ React.createElement("strong", null, "\u7BA1\u7406\u5BC6\u7801"),
2019
+ "\u540E\u518D\u5BF9\u5916\u5F00\u653E\u3002"
2020
+ )
1928
2021
  ),
1929
2022
  React.createElement(
1930
2023
  React.Fragment,
@@ -3915,6 +4008,10 @@ function BridgePanel({ rpcCall }) {
3915
4008
  (serverUrl, accessToken) => act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken }),
3916
4009
  [act]
3917
4010
  );
4011
+ const saveExternalTunnel = React.useCallback(
4012
+ (url) => act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url }),
4013
+ [act]
4014
+ );
3918
4015
  const navSecurity = React.useCallback(() => setActiveTab("security"), []);
3919
4016
  if (!status && !err) {
3920
4017
  return React.createElement("div", {
@@ -3948,6 +4045,7 @@ function BridgePanel({ rpcCall }) {
3948
4045
  })
3949
4046
  );
3950
4047
  } else if (activeTab === "tunnel") {
4048
+ const ext = status?.externalTunnel;
3951
4049
  tabContent = React.createElement(
3952
4050
  React.Fragment,
3953
4051
  null,
@@ -3976,6 +4074,10 @@ function BridgePanel({ rpcCall }) {
3976
4074
  onSave: saveCloudflaredConfig
3977
4075
  })
3978
4076
  ),
4077
+ React.createElement(ExternalTunnelCard, {
4078
+ ext,
4079
+ onSave: saveExternalTunnel
4080
+ }),
3979
4081
  React.createElement(
3980
4082
  TunnelCard,
3981
4083
  {
package/client/index.js CHANGED
@@ -649,6 +649,67 @@ const CloudflareConfigForm = React.memo(function CloudflareConfigForm({ token, h
649
649
  );
650
650
  });
651
651
 
652
+ // 外部已部署隧道登记卡片:用户自行部署(Docker cloudflared / 其他反向代理)时,
653
+ // 插件不下载不管理,仅登记公网地址用于面板展示二维码/URL 与放行 CORS。
654
+ const ExternalTunnelCard = React.memo(function ExternalTunnelCard({ ext, onSave }) {
655
+ const [urlVal, setUrlVal] = React.useState(ext?.url ?? '');
656
+ const [saving, setSaving] = React.useState(false);
657
+ const [msg, setMsg] = React.useState(null);
658
+
659
+ React.useEffect(() => {
660
+ setUrlVal(ext?.url ?? '');
661
+ }, [ext?.url]);
662
+
663
+ const handleSave = async (e) => {
664
+ e.preventDefault();
665
+ setSaving(true);
666
+ setMsg(null);
667
+ try {
668
+ await onSave(urlVal.trim());
669
+ setMsg({ ok: true, text: urlVal.trim() ? '✓ 外部隧道地址已登记' : '✓ 已清除登记' });
670
+ } catch (err) {
671
+ setMsg({ ok: false, text: err.message || '保存失败' });
672
+ } finally {
673
+ setSaving(false);
674
+ }
675
+ };
676
+
677
+ return React.createElement('div', { style: s.card },
678
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 } },
679
+ React.createElement('div', { style: { flex: '1 1 auto', minWidth: 0 } },
680
+ React.createElement('div', { style: s.label }, '外部已部署隧道'),
681
+ React.createElement('div', { style: { ...s.muted, marginTop: 2 } },
682
+ '已在 Docker / 服务器上自行部署隧道(如 cloudflared)时,登记公网地址即可在面板展示入口与二维码;插件不会重复下载或管理该隧道。'
683
+ ),
684
+ ),
685
+ React.createElement(StatusTag, { running: Boolean(ext?.configured) }),
686
+ ),
687
+ ext?.configured && React.createElement(QrBlock, { url: ext?.url, qr: ext?.qr }),
688
+ React.createElement('form', { onSubmit: handleSave, style: { marginTop: 10, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
689
+ React.createElement('input', {
690
+ style: { ...s.input, flex: '1 1 220px', minWidth: 0 },
691
+ type: 'text',
692
+ placeholder: 'https://tunnel.yourdomain.com 或 trycloudflare 地址',
693
+ value: urlVal,
694
+ onChange: (e) => setUrlVal(e.target.value),
695
+ }),
696
+ React.createElement('button', {
697
+ type: 'submit',
698
+ style: { ...s.btnPri, height: 28, fontSize: 12, padding: '0 12px' },
699
+ disabled: saving,
700
+ }, saving ? '保存中…' : '保存'),
701
+ ext?.configured && React.createElement('button', {
702
+ type: 'button',
703
+ style: { ...s.btnGhost, height: 28, fontSize: 12, padding: '0 10px' },
704
+ onClick: () => { setUrlVal(''); onSave(''); },
705
+ }, '清除'),
706
+ msg && React.createElement('span', {
707
+ style: { fontSize: 12, color: msg.ok ? 'var(--dsw-alias-state-success-primary, #059669)' : 'var(--dsw-alias-state-error-primary, #dc2626)' },
708
+ }, msg.text),
709
+ ),
710
+ );
711
+ });
712
+
652
713
  // ---- 访问安全认证卡片 ----
653
714
 
654
715
  const AccessAuthCard = React.memo(function AccessAuthCard({ auth, rpcCall, onUpdate }) {
@@ -882,6 +943,19 @@ const AccessAuthCard = React.memo(function AccessAuthCard({ auth, rpcCall, onUpd
882
943
  color: topMsg.ok ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-state-error-primary,#dc2626)',
883
944
  },
884
945
  }, topMsg.text),
946
+
947
+ // 访问认证已开启但尚未设置任何密码/Token 保护:局域网/公网访客可免密进入,高危提示
948
+ enabled && !auth?.hasPassword && !auth?.hasAdminPassword && React.createElement('div', {
949
+ style: {
950
+ marginTop: 12, padding: '10px 14px', borderRadius: 6, fontSize: 12,
951
+ background: 'var(--dsw-alias-state-error-bg,#fef2f2)',
952
+ border: '1px solid var(--dsw-alias-state-error-border,#fecaca)',
953
+ color: 'var(--dsw-alias-state-error-primary,#dc2626)',
954
+ lineHeight: 1.6,
955
+ },
956
+ }, '⚠️ ', React.createElement('strong', null, '尚未设置任何访问密码或管理密码'),
957
+ ' —— 此时“访问认证已开启”但任何知道局域网 IP / 隧道地址的人都能直接进入(隧道入口自身不设防,详见下方安全须知)。',
958
+ '建议立即在下方设置', React.createElement('strong', null, '访客访问密码'), '或', React.createElement('strong', null, '管理密码'), '后再对外开放。'),
885
959
  ),
886
960
 
887
961
  React.createElement(React.Fragment, null,
@@ -2707,6 +2781,10 @@ function BridgePanel({ rpcCall }) {
2707
2781
  act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken })
2708
2782
  , [act]);
2709
2783
 
2784
+ const saveExternalTunnel = React.useCallback((url) =>
2785
+ act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url })
2786
+ , [act]);
2787
+
2710
2788
  const navSecurity = React.useCallback(() => setActiveTab('security'), []);
2711
2789
 
2712
2790
  if (!status && !err) {
@@ -2744,6 +2822,7 @@ function BridgePanel({ rpcCall }) {
2744
2822
  })
2745
2823
  );
2746
2824
  } else if (activeTab === 'tunnel') {
2825
+ const ext = status?.externalTunnel;
2747
2826
  tabContent = React.createElement(React.Fragment, null,
2748
2827
  React.createElement(TunnelCard, {
2749
2828
  title: 'Cloudflare 隧道',
@@ -2770,6 +2849,10 @@ function BridgePanel({ rpcCall }) {
2770
2849
  onSave: saveCloudflaredConfig,
2771
2850
  }),
2772
2851
  ),
2852
+ React.createElement(ExternalTunnelCard, {
2853
+ ext,
2854
+ onSave: saveExternalTunnel,
2855
+ }),
2773
2856
  React.createElement(TunnelCard, {
2774
2857
  title: '自建隧道',
2775
2858
  desc: '连接自己部署的隧道服务器,获得固定域名',
@@ -12,6 +12,7 @@ export const BRIDGE_ENDPOINTS = {
12
12
  saveCloudflaredConfig: 'saveCloudflaredConfig',
13
13
  setTunnelAutoStart: 'setTunnelAutoStart',
14
14
  saveCustomTunnelConfig: 'saveCustomTunnelConfig',
15
+ saveExternalTunnel: 'saveExternalTunnel',
15
16
  setLanIp: 'setLanIp',
16
17
  checkVersion: 'checkVersion',
17
18
  upgradePlugin: 'upgradePlugin',
package/lib/bridge-rpc.js CHANGED
@@ -173,6 +173,16 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
173
173
  return ok(status);
174
174
  }
175
175
 
176
+ if (endpoint === BRIDGE_ENDPOINTS.saveExternalTunnel) {
177
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
178
+ if (adminErr) return adminErr;
179
+
180
+ const { url } = payload;
181
+ await service.saveExternalTunnel({ url });
182
+ const status = await service.getStatus();
183
+ return ok(status);
184
+ }
185
+
176
186
  if (endpoint === BRIDGE_ENDPOINTS.setTunnelAutoStart) {
177
187
  const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
178
188
  if (adminErr) return adminErr;
@@ -182,8 +182,9 @@ export class FeishuConversationNode extends ConversationBridge {
182
182
  const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
183
183
  const pending = this.pending.get(approvalId)
184
184
  if (pending) {
185
- // 仅审批发起者可决议(与 /yes 的 senderId 校验同一防线)
186
- if (pending.peerId && operatorId && pending.peerId !== operatorId) {
185
+ // 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
186
+ // 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
187
+ if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
187
188
  this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
188
189
  return
189
190
  }
@@ -390,8 +391,13 @@ export class FeishuConversationNode extends ConversationBridge {
390
391
  }
391
392
  req.signal?.addEventListener('abort', onSignalAbort, { once: true })
392
393
 
393
- // peerId 记录发起者:/yes 与卡片按钮都校验决议者身份
394
- this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
394
+ // peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
395
+ // 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
396
+ this.registerApproval(number, {
397
+ number, request: req, resolve: resolveIm, timer,
398
+ peerId: initiator,
399
+ isGroup: Boolean(peer.isGroup),
400
+ })
395
401
 
396
402
  let outcome
397
403
  try {
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { TelegramService } from './telegram/index.js';
26
26
  import { AuthManager } from './auth/manager.js';
27
27
  import { renderLoginPage } from './auth/login-template.js';
28
28
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
+ import { stripSessionProjections } from './session-strip.js';
29
30
  import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
30
31
 
31
32
  const name = 'dsh-bridge';
@@ -245,6 +246,36 @@ function loopbackHeaders(headers, targetPort) {
245
246
  return out;
246
247
  }
247
248
 
249
+ // hop-by-hop 响应头黑名单:这些头描述的是"当前一跳"的传输语义,反向代理
250
+ // 透传会干扰客户端对响应体的解析(transfer-encoding/connection 并存、
251
+ // keep-alive 连接复用错乱等)。Node http 层会自动生成正确的传输头,故统一剥离。
252
+ const HOP_BY_HOP_HEADERS = new Set([
253
+ 'connection',
254
+ 'keep-alive',
255
+ 'proxy-authenticate',
256
+ 'proxy-authorization',
257
+ 'te',
258
+ 'trailer',
259
+ 'transfer-encoding',
260
+ 'upgrade',
261
+ ]);
262
+
263
+ /**
264
+ * 净化上游响应头:剔除 hop-by-hop 头,返回可安全 writeHead 的干净头对象。
265
+ * 保留原始键大小写(Node writeHead 会归一化处理)。
266
+ * @param {object} headers 上游响应头(IncomingMessage.headers 形式)
267
+ * @param {Set<string>} [extra] 额外需剔除的头(小写)
268
+ */
269
+ function sanitizeProxyHeaders(headers, extra) {
270
+ const out = {};
271
+ for (const [k, v] of Object.entries(headers)) {
272
+ if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
273
+ if (extra && extra.has(k.toLowerCase())) continue;
274
+ out[k] = v;
275
+ }
276
+ return out;
277
+ }
278
+
248
279
  /**
249
280
  * HTTP + WebSocket 代理服务器(带安全认证守门)
250
281
  * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
@@ -431,27 +462,42 @@ class ProxyServer {
431
462
  { host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
432
463
  (proxyRes) => {
433
464
  const contentType = String(proxyRes.headers['content-type'] ?? '');
434
- // 未压缩的 HTML 文档注入 polyfill
435
- if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
465
+ // 会话投影剥离(session.list / session.history):这两个端点响应体可能达
466
+ // 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
467
+ // 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
468
+ const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
469
+ const shouldBuffer = contentType.includes('text/html')
470
+ || (isSessionProjection && proxyRes.statusCode === 200 && !isCompressed(proxyRes.headers));
471
+ if (shouldBuffer) {
436
472
  const chunks = [];
437
473
  proxyRes.on('data', (c) => chunks.push(c));
438
474
  proxyRes.on('end', () => {
439
- let html = Buffer.concat(chunks).toString('utf8');
440
- if (!html.includes(INJECT_MARK)) {
441
- html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
442
- }
443
- const out = Buffer.from(html, 'utf8');
475
+ let out = Buffer.concat(chunks);
444
476
  const outHeaders = { ...proxyRes.headers };
477
+ if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
478
+ let html = out.toString('utf8');
479
+ if (!html.includes(INJECT_MARK)) {
480
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
481
+ }
482
+ out = Buffer.from(html, 'utf8');
483
+ } else if (isSessionProjection) {
484
+ const { body, stripped } = stripSessionProjections(pathname, out);
485
+ if (stripped) out = body;
486
+ }
487
+ // 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
488
+ // transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
445
489
  delete outHeaders['content-length'];
446
490
  delete outHeaders['transfer-encoding'];
447
491
  outHeaders['content-length'] = String(out.length);
448
- res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
492
+ res.writeHead(proxyRes.statusCode ?? 200, sanitizeProxyHeaders(outHeaders));
449
493
  res.end(out);
450
494
  });
451
495
  proxyRes.on('error', () => res.destroy());
452
496
  return;
453
497
  }
454
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
498
+ // 直通分支同样剔除 hop-by-hop 头(transfer-encoding/connection 等),
499
+ // 防止透传给客户端导致解析错误;Node 会按 writeHead+pipe 自动生成正确的传输头
500
+ res.writeHead(proxyRes.statusCode ?? 502, sanitizeProxyHeaders(proxyRes.headers));
455
501
  proxyRes.pipe(res);
456
502
  res.on('close', () => proxyRes.destroy());
457
503
  proxyRes.on('error', () => res.destroy());
@@ -481,8 +527,15 @@ class ProxyServer {
481
527
  });
482
528
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
483
529
  socket.write('HTTP/1.1 101 Switching Protocols\r\n');
530
+ // WS 升级成功:手写原始 socket 响应,只放行必要的 101 头(白名单),
531
+ // 避免上游注入任意头(如 set-cookie / 自定义敏感头)透传给浏览器
532
+ const WS_101_ALLOW = new Set([
533
+ 'connection', 'upgrade', 'sec-websocket-accept',
534
+ 'sec-websocket-extensions', 'sec-websocket-protocol',
535
+ ]);
484
536
  const raw = [];
485
537
  for (const [k, v] of Object.entries(proxyRes.headers)) {
538
+ if (!WS_101_ALLOW.has(k.toLowerCase())) continue;
486
539
  raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
487
540
  }
488
541
  socket.write(`${raw.join('\r\n')}\r\n\r\n`);
@@ -500,7 +553,8 @@ class ProxyServer {
500
553
  if (proxyRes.statusCode === 101) return;
501
554
  try {
502
555
  const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
503
- for (const [k, v] of Object.entries(proxyRes.headers)) {
556
+ // 101(如后端拒绝升级):剔除 hop-by-hop 头后再回写
557
+ for (const [k, v] of Object.entries(sanitizeProxyHeaders(proxyRes.headers))) {
504
558
  raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
505
559
  }
506
560
  socket.end(raw.join('\r\n') + '\r\n\r\n');
@@ -556,6 +610,7 @@ class BridgeService {
556
610
  this.home = home;
557
611
  this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
558
612
  this.customTunnelConfig = customTunnelConfig ?? null;
613
+ this.externalTunnelConfig = null; // 用户自行部署的外部隧道(Docker cloudflared 等),仅登记公网地址展示
559
614
  this.selectedLanIp = lanConfig?.selectedIp ?? null;
560
615
  this.authManager = authManager ?? null;
561
616
  this.onPersist = onPersist ?? null;
@@ -573,6 +628,10 @@ class BridgeService {
573
628
  // DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
574
629
  this._dshVersion = null;
575
630
  this._dshVersionLoaded = false;
631
+ // 版本检查结果缓存(10 分钟 TTL):客户端轮询/多处面板重复调用时不反复打 npm registry
632
+ this._versionCheckCache = null;
633
+ this._versionCheckCachedAt = 0;
634
+ this._versionCheckTtlMs = 10 * 60 * 1000;
576
635
  }
577
636
 
578
637
  /**
@@ -582,7 +641,9 @@ class BridgeService {
582
641
  */
583
642
  async getDshVersion() {
584
643
  if (this._dshVersionLoaded) return this._dshVersion;
585
- this._dshVersionLoaded = true; // 只尝试一次,失败也不重试(避免每次 getStatus 都 spawn)
644
+ // 上次探测失败后的退避期未到:不 spawn,直接返回 null(面板仍可正常加载)
645
+ if (this._dshVersionRetryAt && Date.now() < this._dshVersionRetryAt) return this._dshVersion;
646
+ this._dshVersionLoaded = true; // 尝试期间置位;成功保持,失败在 catch 里复位并设退避
586
647
 
587
648
  const isWin = process.platform === 'win32';
588
649
  const nodeDir = dirname(process.execPath);
@@ -623,6 +684,10 @@ class BridgeService {
623
684
  } catch (e) {
624
685
  this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
625
686
  this._dshVersion = null;
687
+ // 失败:复位已加载标记并设 10 分钟退避期。退避期内 getStatus 不再 spawn,
688
+ // 到期后允许重试一次(用户修复 PATH 后无需重启即可恢复版本显示)。
689
+ this._dshVersionLoaded = false;
690
+ this._dshVersionRetryAt = Date.now() + 10 * 60 * 1000;
626
691
  }
627
692
  return this._dshVersion;
628
693
  }
@@ -643,14 +708,20 @@ class BridgeService {
643
708
  targetPort: this.dshPort,
644
709
  authManager: this.authManager,
645
710
  logger: this.logger,
646
- // loopback-token 允许跨域的面板来源:回环、当前局域网 IP、隧道公网地址
711
+ // loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
712
+ // 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
713
+ // 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
647
714
  allowedOrigins: () => {
648
- const origins = [`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`];
715
+ const origins = [
716
+ `http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`,
717
+ `http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
718
+ ];
649
719
  try {
650
720
  for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
651
721
  if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
652
722
  if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
653
723
  if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
724
+ if (this.externalTunnelConfig?.url) origins.push(new URL(this.externalTunnelConfig.url).origin);
654
725
  } catch { /* 单项来源解析失败不影响其余 */ }
655
726
  return origins;
656
727
  },
@@ -739,6 +810,15 @@ class BridgeService {
739
810
  autoStart: Boolean(this.customTunnelConfig?.autoStart),
740
811
  },
741
812
 
813
+ // 外部已部署隧道(用户自行 Docker/二进制部署,插件仅登记展示)
814
+ externalTunnel: {
815
+ configured: !!this.externalTunnelConfig?.url,
816
+ url: this.externalTunnelConfig?.url ?? '',
817
+ qr: this.externalTunnelConfig?.url
818
+ ? await this.qrCache.get(this.externalTunnelConfig.url)
819
+ : null,
820
+ },
821
+
742
822
  // 宿主系统运行监控指标
743
823
  system: this.getSystemMetrics(),
744
824
  };
@@ -755,6 +835,27 @@ class BridgeService {
755
835
  await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
756
836
  }
757
837
 
838
+ // 登记用户自行部署的外部隧道(Docker cloudflared 等)。插件不下载/不管理该隧道,
839
+ // 仅保存公网地址用于面板展示二维码/URL 并放行 CORS。url 传空串则清除登记。
840
+ async saveExternalTunnel({ url } = {}) {
841
+ const trimmed = url ? String(url).trim() : '';
842
+ if (trimmed) {
843
+ // 仅接受 http/https 公网地址,防止注入任意协议或本地路径
844
+ let normalized;
845
+ try {
846
+ const u = new URL(trimmed);
847
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('仅支持 http/https');
848
+ normalized = u.toString().replace(/\/+$/, '');
849
+ } catch {
850
+ throw new Error('请输入合法的公网隧道地址(https://...)');
851
+ }
852
+ this.externalTunnelConfig = { url: normalized };
853
+ } else {
854
+ this.externalTunnelConfig = null;
855
+ }
856
+ await this.onPersist?.({ externalTunnel: this.externalTunnelConfig });
857
+ }
858
+
758
859
  async setTunnelAutoStart({ tunnel, autoStart }) {
759
860
  const isAuto = Boolean(autoStart);
760
861
  if (tunnel === 'cloudflared') {
@@ -883,6 +984,10 @@ class BridgeService {
883
984
 
884
985
  // 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
885
986
  async checkVersion() {
987
+ // TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
988
+ if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
989
+ return this._versionCheckCache;
990
+ }
886
991
  const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
887
992
  const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
888
993
  if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
@@ -907,13 +1012,17 @@ class BridgeService {
907
1012
  try {
908
1013
  const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
909
1014
  .catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
910
- return {
1015
+ const result = {
911
1016
  current: VERSION,
912
1017
  latest: latestData?.version ?? null,
913
1018
  releaseNotes: latestData?.releaseNotes ?? null,
914
1019
  dshVersion: await this.getDshVersion(),
915
1020
  };
1021
+ this._versionCheckCache = result;
1022
+ this._versionCheckCachedAt = Date.now();
1023
+ return result;
916
1024
  } catch (e) {
1025
+ // 失败不缓存(让下次调用可重试),但记录便于诊断
917
1026
  return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
918
1027
  }
919
1028
  }
@@ -1595,6 +1704,9 @@ function apply(ctx, config = {}) {
1595
1704
 
1596
1705
  // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
1597
1706
  loadConfig().then(async (stored) => {
1707
+ if (stored?.externalTunnel) {
1708
+ service.externalTunnelConfig = stored.externalTunnel;
1709
+ }
1598
1710
  if (stored?.lan?.selectedIp) {
1599
1711
  service.selectedLanIp = stored.lan.selectedIp;
1600
1712
  logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);