@wenbin_wb/dsh-bridge 2.10.0 → 2.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,25 +4,37 @@
4
4
 
5
5
  ---
6
6
 
7
- ## [v2.10.0] - 2026-09-01
7
+ ## [v2.10.2] - 2026-09-01
8
+
9
+ ### ✨ 新功能
10
+
11
+ - **外部已部署隧道登记**(issue #25):自行部署 cloudflared(Docker 等)的用户可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理
12
+
13
+ ### 🐞 修复
14
+
15
+ - **修复会话列表/历史加载问题**:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)
16
+
17
+ ---
18
+
19
+ ## [v2.10.1] - 2026-09-01
8
20
 
9
- > **本次为远程锁屏/解锁流程修复版**:重点修复远程访问场景下锁屏不显示、解锁后无限循环、会话失效后无法恢复等一连串问题,并新增「管理保护」独立开关与 DSH 宿主版本显示。建议升级前备份 `~/.dsh/dsh-bridge/config.json`。
21
+ ### 🧹 说明
10
22
 
11
- ### 🔐 远程锁屏/解锁流程修复
23
+ - 无代码变更:修正 npm 包内升级提示(releaseNotes)措辞,与 v2.10.0 发布说明保持一致(仅突出新增功能,不再罗列内部修复细节)。
12
24
 
13
- - **修复解锁后无限锁屏循环**:DSH 重启或访问会话过期后,旧 cookie 失效,所有接口被代理以 HTTP 401 拦截。此前即使管理密码解锁成功(`authAdminUnlock` 已豁免),解锁后 `getStatus` 依旧 401 → 面板把锁重置回 true → 无限循环。现在检测到访问会话失效(HTTP 401 / transport failure)时,**整页重载回绿色图标登录页**重新输入访问密码获取新会话,不再困在锁屏;带防循环保护(重载一次未恢复则提示手动刷新)。
14
- - **修复锁屏状态下无法解锁(401)**:锁屏时访问会话可能已失效,`authAdminUnlock` 请求此前也被 HTTP 访问认证拦截,导致锁屏永远解不开;现豁免该端点的访问认证(内部仍校验管理密码,安全不受影响)。
15
- - **修复关闭「访问认证」后锁屏不显示**:锁屏此前依赖访问认证开关,关闭后远程设备不再锁定(管理保护形同虚设);现管理保护独立生效,关闭访问认证后远程设备仍显示锁屏,仅凭管理密码可解锁。
16
- - **修复关闭「访问认证」后仍要求密码**:`enabled` 开关豁免管理 gate(否则"关闭访问认证"这个动作本身会被管理保护锁死)。
17
- - **修复远程首次进入设置页不显示锁屏**:现在远程设备首次进入即显示完整全屏锁屏(输入管理密码后方可操作)。
18
- - **修复远程目录选择器卡死**:目录浏览被鉴权拦截时内嵌解锁入口,解锁后自动继续;`local_only` 策略下明确提示远程不可解锁。
19
- - **修复解锁成功后操作丢失**:被拦截的操作暂存队列,解锁成功后自动重放,无需重新点击。
25
+ ---
26
+
27
+ ## [v2.10.0] - 2026-09-01
20
28
 
21
29
  ### ✨ 新功能
22
30
 
23
- - **新增「管理保护」独立开关**:与访问认证解耦的独立安全开关(安全认证页可切换)。关闭后管理操作免 adminToken,与访问认证开关互不干扰。
31
+ - **新增「管理保护」独立开关**:与访问认证解耦的独立安全开关(安全认证页可切换)。此前远程锁屏依附于访问认证开关,关闭访问认证后管理保护随之失效;现在两者完全独立,远程设备的管理权限始终受「管理保护」约束,仅凭管理密码可解锁。
24
32
  - **面板显示 DSH 宿主版本**:版本条新增灰色 `DSH vX.Y.Z` 标签(服务端惰性探测 `dsh --version` 并缓存,零轮询开销),与插件版本并排展示。
25
33
 
34
+ ### 🐞 修复
35
+
36
+ - 修复远程锁屏/解锁流程的多处问题:会话失效(DSH 重启/过期)后自动跳回登录页重新登录,不再困在锁屏;锁屏状态下可正常输入管理密码解锁;关闭访问认证后锁屏仍生效、且不再误要求密码;远程首次进入设置页即显示全屏锁屏;目录选择器解锁不再卡死。
37
+
26
38
  ### 🧹 内部优化
27
39
 
28
40
  - 统一 adminToken 缓存为单一来源,删除死代码与重复实现(unlock-manager 未接入 API、组件内冗余 state、重复的 loopback-token 逻辑等)。
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");
@@ -3915,6 +3984,10 @@ function BridgePanel({ rpcCall }) {
3915
3984
  (serverUrl, accessToken) => act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken }),
3916
3985
  [act]
3917
3986
  );
3987
+ const saveExternalTunnel = React.useCallback(
3988
+ (url) => act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url }),
3989
+ [act]
3990
+ );
3918
3991
  const navSecurity = React.useCallback(() => setActiveTab("security"), []);
3919
3992
  if (!status && !err) {
3920
3993
  return React.createElement("div", {
@@ -3948,6 +4021,7 @@ function BridgePanel({ rpcCall }) {
3948
4021
  })
3949
4022
  );
3950
4023
  } else if (activeTab === "tunnel") {
4024
+ const ext = status?.externalTunnel;
3951
4025
  tabContent = React.createElement(
3952
4026
  React.Fragment,
3953
4027
  null,
@@ -3976,6 +4050,10 @@ function BridgePanel({ rpcCall }) {
3976
4050
  onSave: saveCloudflaredConfig
3977
4051
  })
3978
4052
  ),
4053
+ React.createElement(ExternalTunnelCard, {
4054
+ ext,
4055
+ onSave: saveExternalTunnel
4056
+ }),
3979
4057
  React.createElement(
3980
4058
  TunnelCard,
3981
4059
  {
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 }) {
@@ -2707,6 +2768,10 @@ function BridgePanel({ rpcCall }) {
2707
2768
  act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken })
2708
2769
  , [act]);
2709
2770
 
2771
+ const saveExternalTunnel = React.useCallback((url) =>
2772
+ act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url })
2773
+ , [act]);
2774
+
2710
2775
  const navSecurity = React.useCallback(() => setActiveTab('security'), []);
2711
2776
 
2712
2777
  if (!status && !err) {
@@ -2744,6 +2809,7 @@ function BridgePanel({ rpcCall }) {
2744
2809
  })
2745
2810
  );
2746
2811
  } else if (activeTab === 'tunnel') {
2812
+ const ext = status?.externalTunnel;
2747
2813
  tabContent = React.createElement(React.Fragment, null,
2748
2814
  React.createElement(TunnelCard, {
2749
2815
  title: 'Cloudflare 隧道',
@@ -2770,6 +2836,10 @@ function BridgePanel({ rpcCall }) {
2770
2836
  onSave: saveCloudflaredConfig,
2771
2837
  }),
2772
2838
  ),
2839
+ React.createElement(ExternalTunnelCard, {
2840
+ ext,
2841
+ onSave: saveExternalTunnel,
2842
+ }),
2773
2843
  React.createElement(TunnelCard, {
2774
2844
  title: '自建隧道',
2775
2845
  desc: '连接自己部署的隧道服务器,获得固定域名',
@@ -0,0 +1,40 @@
1
+ # 发布流程规则(RELEASE PROCESS)
2
+
3
+ > 本文件是**发布操作的红线规则**,任何一次对外发布(npm / GitHub Release)都必须遵守。
4
+
5
+ ## 铁律:发布前必须人工确认
6
+
7
+ **任何版本(正式版 / 补丁版)在推 tag、npm publish、创建 GitHub Release 之前,必须先向仓库所有者(wenbin-wb)确认,得到明确同意后才可执行。**
8
+
9
+ 原因(2026-09-01 教训):v2.10.0 发布时未先确认就自行完成了 合并 → 打 tag → 推送 → npm publish 全流程,随后发现发布说明措辞需要修订,而 npm 不允许覆盖已发布版本,只能再补 v2.10.1 修正,造成不必要的版本噪音。
10
+
11
+ ### 确认清单(发布前发给所有者)
12
+
13
+ 1. **版本号**:下一个版本号(如 `v2.10.1`)是否认可?
14
+ 2. **发布说明(releaseNotes / CHANGELOG)**:内容是否准确?是否只包含用户可感知的变更(新功能、真实修复),不包含开发过程细节?
15
+ 3. **发布范围**:npm 发布 + GitHub Release + tag,是否全部执行?
16
+
17
+ 得到「可以发布」的明确回复后,才允许执行发布动作。
18
+
19
+ ## 发布流程
20
+
21
+ 1. **改版本**:`package.json` 的 `version` + `releaseNotes`
22
+ 2. **写 CHANGELOG**:`CHANGELOG.md` 顶部新增对应版本条目
23
+ 3. **构建与测试**:`npm run build:lark` + `npm run build:client` + `npm run build:banner` + `npm test`(全量 166 项)
24
+ 4. **提交**:合并到 `main`(fast-forward),提交信息 `release: vX.Y.Z — ...`
25
+ 5. **打 tag**:`git tag vX.Y.Z && git push origin vX.Y.Z`(触发 GitHub Actions 自动创建 Release)
26
+ 6. **npm 发布**:`npm publish`(prepack 自动构建产物)
27
+ 7. **验证**:npm 线上版本 + GitHub Release body 是否正确
28
+
29
+ ## 发布说明写作规范
30
+
31
+ - ✅ 只写**用户可感知**的变更:新功能、真实修复(用户报告的问题)
32
+ - ❌ 不写开发过程中自己引入又修复的内部 bug
33
+ - ❌ 不罗列内部重构/清理细节(一句话概括即可)
34
+ - ✅ 修复类条目建议**合并成一条概括**,不逐条展开技术实现
35
+
36
+ ## 注意事项
37
+
38
+ - npm **不允许覆盖已发布版本**(403)。发布后发现说明要改,只能 bump 补丁版本——所以发布前确认说明尤其重要。
39
+ - `release.yml` workflow 只创建 GitHub Release,npm 发布是手动步骤,不要漏。
40
+ - 发布后如发现问题,走补丁版本(`vX.Y.Z+1`),不要尝试覆盖。
@@ -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;
package/lib/index.js CHANGED
@@ -556,6 +556,7 @@ class BridgeService {
556
556
  this.home = home;
557
557
  this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
558
558
  this.customTunnelConfig = customTunnelConfig ?? null;
559
+ this.externalTunnelConfig = null; // 用户自行部署的外部隧道(Docker cloudflared 等),仅登记公网地址展示
559
560
  this.selectedLanIp = lanConfig?.selectedIp ?? null;
560
561
  this.authManager = authManager ?? null;
561
562
  this.onPersist = onPersist ?? null;
@@ -651,6 +652,7 @@ class BridgeService {
651
652
  if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
652
653
  if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
653
654
  if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
655
+ if (this.externalTunnelConfig?.url) origins.push(new URL(this.externalTunnelConfig.url).origin);
654
656
  } catch { /* 单项来源解析失败不影响其余 */ }
655
657
  return origins;
656
658
  },
@@ -739,6 +741,15 @@ class BridgeService {
739
741
  autoStart: Boolean(this.customTunnelConfig?.autoStart),
740
742
  },
741
743
 
744
+ // 外部已部署隧道(用户自行 Docker/二进制部署,插件仅登记展示)
745
+ externalTunnel: {
746
+ configured: !!this.externalTunnelConfig?.url,
747
+ url: this.externalTunnelConfig?.url ?? '',
748
+ qr: this.externalTunnelConfig?.url
749
+ ? await this.qrCache.get(this.externalTunnelConfig.url)
750
+ : null,
751
+ },
752
+
742
753
  // 宿主系统运行监控指标
743
754
  system: this.getSystemMetrics(),
744
755
  };
@@ -755,6 +766,27 @@ class BridgeService {
755
766
  await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
756
767
  }
757
768
 
769
+ // 登记用户自行部署的外部隧道(Docker cloudflared 等)。插件不下载/不管理该隧道,
770
+ // 仅保存公网地址用于面板展示二维码/URL 并放行 CORS。url 传空串则清除登记。
771
+ async saveExternalTunnel({ url } = {}) {
772
+ const trimmed = url ? String(url).trim() : '';
773
+ if (trimmed) {
774
+ // 仅接受 http/https 公网地址,防止注入任意协议或本地路径
775
+ let normalized;
776
+ try {
777
+ const u = new URL(trimmed);
778
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('仅支持 http/https');
779
+ normalized = u.toString().replace(/\/+$/, '');
780
+ } catch {
781
+ throw new Error('请输入合法的公网隧道地址(https://...)');
782
+ }
783
+ this.externalTunnelConfig = { url: normalized };
784
+ } else {
785
+ this.externalTunnelConfig = null;
786
+ }
787
+ await this.onPersist?.({ externalTunnel: this.externalTunnelConfig });
788
+ }
789
+
758
790
  async setTunnelAutoStart({ tunnel, autoStart }) {
759
791
  const isAuto = Boolean(autoStart);
760
792
  if (tunnel === 'cloudflared') {
@@ -1595,6 +1627,9 @@ function apply(ctx, config = {}) {
1595
1627
 
1596
1628
  // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
1597
1629
  loadConfig().then(async (stored) => {
1630
+ if (stored?.externalTunnel) {
1631
+ service.externalTunnelConfig = stored.externalTunnel;
1632
+ }
1598
1633
  if (stored?.lan?.selectedIp) {
1599
1634
  service.selectedLanIp = stored.lan.selectedIp;
1600
1635
  logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
@@ -1,426 +1,440 @@
1
- // DSH Bridge - Custom Tunnel Client
2
- import { WebSocket } from 'ws';
3
- import { request as httpRequest } from 'node:http';
4
- import { connect as netConnect } from 'node:net';
5
- import { promisify } from 'node:util';
6
- import { gzip as gzipCallback } from 'node:zlib';
7
-
8
- const gzipAsync = promisify(gzipCallback);
9
-
10
- const HEARTBEAT_INTERVAL = 30000;
11
- const RECONNECT_DELAY = 5000;
12
- const MAX_RECONNECT_ATTEMPTS = 5;
13
-
14
- // 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
15
- // 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
16
- const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
17
-
18
- // 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
19
- const GZIP_THRESHOLD = 102400; // 100KB
20
- // 可压缩的 content-type 前缀
21
- const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
22
-
23
- export class CustomTunnelClient {
24
- constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
25
- this.serverUrl = serverUrl;
26
- this.accessToken = accessToken;
27
- this.localPort = localPort;
28
- this.internalTunnelSecret = internalTunnelSecret;
29
- this.signal = signal;
30
- this.onStateChange = onStateChange;
31
- this.logger = logger;
32
- this.ws = null;
33
- this.publicUrl = null;
34
- this.connected = false;
35
- this.disconnecting = false;
36
- this.reconnectAttempts = 0;
37
- this.reconnectTimer = null;
38
- this.heartbeatTimer = null;
39
- this.localWsSockets = new Map(); // wsId -> net.Socket
40
- }
41
-
42
- async connect() {
43
- if (this.connected) return;
44
- this._setState('connecting', 'Connecting to tunnel server...');
45
- try {
46
- await this._connectWebSocket();
47
- this._startHeartbeat();
48
- this.reconnectAttempts = 0;
49
- this._setState('ready', 'Tunnel established');
50
- } catch (err) {
51
- this._setState('error', err.message);
52
- throw err;
53
- }
54
- }
55
-
56
- _connectWebSocket() {
57
- return new Promise((resolve, reject) => {
58
- if (this.signal?.aborted) return reject(new Error('Aborted'));
59
-
60
- const url = new URL(this.serverUrl);
61
- url.searchParams.set('token', this.accessToken);
62
-
63
- this.ws = new WebSocket(url.toString(), {
64
- handshakeTimeout: 10000,
65
- perMessageDeflate: {
66
- clientNoContextTakeover: true,
67
- serverNoContextTakeover: true,
68
- clientMaxWindowBits: 15,
69
- serverMaxWindowBits: 15,
70
- },
71
- });
72
-
73
- const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
74
- this.signal?.addEventListener('abort', onAbort);
75
-
76
- this.ws.on('open', () => {
77
- this.signal?.removeEventListener('abort', onAbort);
78
- this.logger?.info('Tunnel WebSocket connected');
79
- });
80
-
81
- this.ws.on('message', (data) => this._handleMessage(data));
82
-
83
- this.ws.on('close', (code, reason) => {
84
- this.connected = false;
85
- this._stopHeartbeat();
86
- this._cleanupLocalWs();
87
- if (!this.signal?.aborted) {
88
- this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
89
- this._scheduleReconnect();
90
- }
91
- });
92
-
93
- this.ws.on('error', (err) => {
94
- this.logger?.error('Tunnel WebSocket error: %s', err.message);
95
- if (!this.connected) {
96
- this.signal?.removeEventListener('abort', onAbort);
97
- reject(err);
98
- }
99
- });
100
-
101
- const readyHandler = (data) => {
102
- try {
103
- const msg = JSON.parse(data.toString());
104
- if (msg.type === 'ready' && msg.publicUrl) {
105
- this.publicUrl = msg.publicUrl;
106
- this.connected = true;
107
- this.ws.off('message', readyHandler);
108
- this.signal?.removeEventListener('abort', onAbort);
109
- this.logger?.info('Tunnel ready: %s', this.publicUrl);
110
- resolve();
111
- }
112
- } catch {}
113
- };
114
- this.ws.on('message', readyHandler);
115
-
116
- setTimeout(() => {
117
- if (!this.connected) {
118
- this.signal?.removeEventListener('abort', onAbort);
119
- this.ws?.terminate();
120
- reject(new Error('Connection timeout'));
121
- }
122
- }, 15000);
123
- });
124
- }
125
-
126
- _handleMessage(data) {
127
- try {
128
- const msg = JSON.parse(data.toString());
129
- if (msg.type === 'request') this._handleHttpRequest(msg);
130
- else if (msg.type === 'ws-open') this._handleWsOpen(msg);
131
- else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
132
- else if (msg.type === 'ws-close') this._handleWsClose(msg);
133
- // pong: ignore
134
- } catch (err) {
135
- this.logger?.error('Failed to parse tunnel message: %s', err.message);
136
- }
137
- }
138
-
139
- // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
140
- _handleHttpRequest(msg) {
141
- const { requestId, method, path, headers } = msg;
142
- const SKIP = new Set(['transfer-encoding','connection','keep-alive',
143
- 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
144
- const safeHeaders = Object.fromEntries(
145
- Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
146
- );
147
-
148
- const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
149
- if (this.internalTunnelSecret) {
150
- reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
151
- }
152
-
153
- const req = httpRequest({
154
- host: '127.0.0.1', port: this.localPort,
155
- method, path: path || '/',
156
- headers: reqHeaders,
157
- }, (res) => {
158
- const contentType = String(res.headers['content-type'] ?? '');
159
- const isSSE = contentType.includes('text/event-stream');
160
- const chunks = [];
161
-
162
- if (isSSE) {
163
- // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
164
- // 避免 SSE 永不 end 导致隧道服务器超时返回 504
165
- let sseSent = false;
166
- const sseTimer = setTimeout(() => {
167
- if (sseSent) return;
168
- sseSent = true;
169
- const respHeaders = Object.fromEntries(
170
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
171
- );
172
- this._sendMessage({
173
- type: 'response', requestId,
174
- statusCode: res.statusCode, headers: respHeaders,
175
- body: Buffer.concat(chunks).toString('base64'),
176
- });
177
- res.destroy();
178
- }, 500);
179
-
180
- res.on('data', (c) => {
181
- if (sseSent) return;
182
- chunks.push(c);
183
- // 收到初始数据后立即发送(不等超时)
184
- if (chunks.length >= 2) {
185
- clearTimeout(sseTimer);
186
- sseSent = true;
187
- const respHeaders = Object.fromEntries(
188
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
189
- );
190
- this._sendMessage({
191
- type: 'response', requestId,
192
- statusCode: res.statusCode, headers: respHeaders,
193
- body: Buffer.concat(chunks).toString('base64'),
194
- });
195
- res.destroy();
196
- }
197
- });
198
- res.on('error', () => {
199
- if (!sseSent) {
200
- clearTimeout(sseTimer);
201
- this._sendMessage({
202
- type: 'response', requestId, statusCode: 502,
203
- headers: { 'content-type': 'text/plain' },
204
- body: Buffer.from('Response Error').toString('base64'),
205
- });
206
- }
207
- });
208
- res.on('end', () => {
209
- if (!sseSent) {
210
- clearTimeout(sseTimer);
211
- sseSent = true;
212
- const respHeaders = Object.fromEntries(
213
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
214
- );
215
- this._sendMessage({
216
- type: 'response', requestId,
217
- statusCode: res.statusCode, headers: respHeaders,
218
- body: Buffer.concat(chunks).toString('base64'),
219
- });
220
- }
221
- });
222
- return;
223
- }
224
-
225
- let responded = false;
226
- const sendResponse = (statusCode, headers, body) => {
227
- if (responded) return;
228
- responded = true;
229
- this._sendMessage({
230
- type: 'response', requestId, statusCode, headers,
231
- body: Buffer.from(body).toString('base64'),
232
- });
233
- };
234
-
235
- let total = 0;
236
- res.on('data', (c) => {
237
- chunks.push(c);
238
- total += c.length;
239
- if (total > MAX_RESPONSE_BYTES) {
240
- sendResponse(502, { 'content-type': 'text/plain' },
241
- `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
242
- res.destroy();
243
- }
244
- });
245
- res.on('error', () => {
246
- sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
247
- });
248
- res.on('end', () => {
249
- if (responded) return;
250
- const respHeaders = Object.fromEntries(
251
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
252
- );
253
- let bodyBuf = Buffer.concat(chunks);
254
-
255
- // session.list 响应可能极大(数百会话 × contextHeaders = 60MB+)
256
- // 剥离 contextHeaders 和 contextTimeline(侧边栏列表不需要,打开会话时通过 WebSocket 实时获取)
257
- const cleanPath = String(path || '').split('?')[0];
258
- if (cleanPath === '/api/session.list' && res.statusCode === 200) {
259
- try {
260
- const json = JSON.parse(bodyBuf.toString('utf8'));
261
- if (json?.result?.ok && json.result.value?.items) {
262
- for (const item of json.result.value.items) {
263
- const proj = item?.projections?.values;
264
- if (proj) {
265
- delete proj.contextHeaders;
266
- delete proj.contextTimeline;
267
- }
268
- }
269
- bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
270
- respHeaders['content-length'] = String(bodyBuf.length);
271
- }
272
- } catch {} // 解析失败则原样发送
273
- }
274
-
275
- // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
276
- const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
277
- const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
278
- const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
279
- if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
280
- gzipAsync(bodyBuf).then((zipped) => {
281
- respHeaders['content-encoding'] = 'gzip';
282
- respHeaders['content-length'] = String(zipped.length);
283
- sendResponse(res.statusCode, respHeaders, zipped);
284
- }).catch(() => {
285
- sendResponse(res.statusCode, respHeaders, bodyBuf);
286
- });
287
- return;
288
- }
289
-
290
- sendResponse(res.statusCode, respHeaders, bodyBuf);
291
- });
292
- });
293
- req.on('error', err => {
294
- this._sendMessage({ type: 'response', requestId, statusCode: 502,
295
- headers: { 'content-type': 'text/plain' },
296
- body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
297
- });
298
- if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
299
- req.end();
300
- }
301
-
302
- // ── WebSocket 升级代理 ────────────────────────────────────────────────────
303
- // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
304
- _handleWsOpen(msg) {
305
- const { wsId, path, headers } = msg;
306
-
307
- const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
308
- this.localWsSockets.set(wsId, sock);
309
-
310
- // 构造 HTTP Upgrade 请求
311
- const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
312
- delete reqHeaders['proxy-connection'];
313
- delete reqHeaders['proxy-authorization'];
314
- if (this.internalTunnelSecret) {
315
- reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
316
- }
317
-
318
- const lines = [`GET ${path || '/'} HTTP/1.1`];
319
- for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
320
- lines.push('', '');
321
- sock.write(lines.join('\r\n'));
322
-
323
- let headerBuf = '';
324
- let upgraded = false;
325
-
326
- sock.on('data', (chunk) => {
327
- if (upgraded) {
328
- this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
329
- return;
330
- }
331
- headerBuf += chunk.toString('binary');
332
- const sep = headerBuf.indexOf('\r\n\r\n');
333
- if (sep === -1) return;
334
-
335
- upgraded = true;
336
- const replyHeaders = {};
337
- const headerLines = headerBuf.slice(0, sep).split('\r\n');
338
- for (let i = 1; i < headerLines.length; i++) {
339
- const ci = headerLines[i].indexOf(':');
340
- if (ci > 0) {
341
- replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
342
- headerLines[i].slice(ci + 1).trim();
343
- }
344
- }
345
- this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
346
-
347
- // 握手后紧跟的帧数据
348
- const rest = headerBuf.slice(sep + 4);
349
- if (rest.length > 0) {
350
- this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
351
- }
352
- });
353
-
354
- sock.on('close', () => {
355
- this._sendMessage({ type: 'ws-close', wsId });
356
- this.localWsSockets.delete(wsId);
357
- });
358
- sock.on('error', (err) => {
359
- this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
360
- this._sendMessage({ type: 'ws-close', wsId });
361
- this.localWsSockets.delete(wsId);
362
- });
363
- }
364
-
365
- _handleWsFrame(msg) {
366
- const sock = this.localWsSockets.get(msg.wsId);
367
- if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
368
- }
369
-
370
- _handleWsClose(msg) {
371
- const sock = this.localWsSockets.get(msg.wsId);
372
- if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
373
- }
374
-
375
- _cleanupLocalWs() {
376
- for (const [, sock] of this.localWsSockets) sock.destroy();
377
- this.localWsSockets.clear();
378
- }
379
-
380
- // ── 工具方法 ──────────────────────────────────────────────────────────────
381
- _sendMessage(msg) {
382
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
383
- }
384
-
385
- _startHeartbeat() {
386
- this._stopHeartbeat();
387
- this.heartbeatTimer = setInterval(() => {
388
- if (this.connected) this._sendMessage({ type: 'ping' });
389
- }, HEARTBEAT_INTERVAL);
390
- }
391
-
392
- _stopHeartbeat() {
393
- if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
394
- }
395
-
396
- _scheduleReconnect() {
397
- if (this.signal?.aborted || this.disconnecting) return;
398
- if (this.reconnectTimer) return;
399
- if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
400
- this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
401
- return;
402
- }
403
- this.reconnectAttempts++;
404
- const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
405
- this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
406
- this.reconnectTimer = setTimeout(() => {
407
- this.reconnectTimer = null;
408
- this.connect().catch(() => {});
409
- }, delay);
410
- }
411
-
412
- _setState(phase, detail) {
413
- if (this.onStateChange) this.onStateChange({ phase, detail });
414
- }
415
-
416
- disconnect() {
417
- this.disconnecting = true;
418
- this._stopHeartbeat();
419
- this._cleanupLocalWs();
420
- if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
421
- if (this.ws) { this.ws.close(); this.ws = null; }
422
- this.connected = false;
423
- this.publicUrl = null;
424
- this.logger?.info('Tunnel disconnected');
425
- }
426
- }
1
+ // DSH Bridge - Custom Tunnel Client
2
+ import { WebSocket } from 'ws';
3
+ import { request as httpRequest } from 'node:http';
4
+ import { connect as netConnect } from 'node:net';
5
+ import { promisify } from 'node:util';
6
+ import { gzip as gzipCallback } from 'node:zlib';
7
+
8
+ const gzipAsync = promisify(gzipCallback);
9
+
10
+ const HEARTBEAT_INTERVAL = 30000;
11
+ const RECONNECT_DELAY = 5000;
12
+ const MAX_RECONNECT_ATTEMPTS = 5;
13
+
14
+ // 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
15
+ // 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
16
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
17
+
18
+ // 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
19
+ const GZIP_THRESHOLD = 102400; // 100KB
20
+ // 可压缩的 content-type 前缀
21
+ const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
22
+
23
+ export class CustomTunnelClient {
24
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
25
+ this.serverUrl = serverUrl;
26
+ this.accessToken = accessToken;
27
+ this.localPort = localPort;
28
+ this.internalTunnelSecret = internalTunnelSecret;
29
+ this.signal = signal;
30
+ this.onStateChange = onStateChange;
31
+ this.logger = logger;
32
+ this.ws = null;
33
+ this.publicUrl = null;
34
+ this.connected = false;
35
+ this.disconnecting = false;
36
+ this.reconnectAttempts = 0;
37
+ this.reconnectTimer = null;
38
+ this.heartbeatTimer = null;
39
+ this.localWsSockets = new Map(); // wsId -> net.Socket
40
+ }
41
+
42
+ async connect() {
43
+ if (this.connected) return;
44
+ this._setState('connecting', 'Connecting to tunnel server...');
45
+ try {
46
+ await this._connectWebSocket();
47
+ this._startHeartbeat();
48
+ this.reconnectAttempts = 0;
49
+ this._setState('ready', 'Tunnel established');
50
+ } catch (err) {
51
+ this._setState('error', err.message);
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ _connectWebSocket() {
57
+ return new Promise((resolve, reject) => {
58
+ if (this.signal?.aborted) return reject(new Error('Aborted'));
59
+
60
+ const url = new URL(this.serverUrl);
61
+ url.searchParams.set('token', this.accessToken);
62
+
63
+ this.ws = new WebSocket(url.toString(), {
64
+ handshakeTimeout: 10000,
65
+ perMessageDeflate: {
66
+ clientNoContextTakeover: true,
67
+ serverNoContextTakeover: true,
68
+ clientMaxWindowBits: 15,
69
+ serverMaxWindowBits: 15,
70
+ },
71
+ });
72
+
73
+ const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
74
+ this.signal?.addEventListener('abort', onAbort);
75
+
76
+ this.ws.on('open', () => {
77
+ this.signal?.removeEventListener('abort', onAbort);
78
+ this.logger?.info('Tunnel WebSocket connected');
79
+ });
80
+
81
+ this.ws.on('message', (data) => this._handleMessage(data));
82
+
83
+ this.ws.on('close', (code, reason) => {
84
+ this.connected = false;
85
+ this._stopHeartbeat();
86
+ this._cleanupLocalWs();
87
+ if (!this.signal?.aborted) {
88
+ this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
89
+ this._scheduleReconnect();
90
+ }
91
+ });
92
+
93
+ this.ws.on('error', (err) => {
94
+ this.logger?.error('Tunnel WebSocket error: %s', err.message);
95
+ if (!this.connected) {
96
+ this.signal?.removeEventListener('abort', onAbort);
97
+ reject(err);
98
+ }
99
+ });
100
+
101
+ const readyHandler = (data) => {
102
+ try {
103
+ const msg = JSON.parse(data.toString());
104
+ if (msg.type === 'ready' && msg.publicUrl) {
105
+ this.publicUrl = msg.publicUrl;
106
+ this.connected = true;
107
+ this.ws.off('message', readyHandler);
108
+ this.signal?.removeEventListener('abort', onAbort);
109
+ this.logger?.info('Tunnel ready: %s', this.publicUrl);
110
+ resolve();
111
+ }
112
+ } catch {}
113
+ };
114
+ this.ws.on('message', readyHandler);
115
+
116
+ setTimeout(() => {
117
+ if (!this.connected) {
118
+ this.signal?.removeEventListener('abort', onAbort);
119
+ this.ws?.terminate();
120
+ reject(new Error('Connection timeout'));
121
+ }
122
+ }, 15000);
123
+ });
124
+ }
125
+
126
+ _handleMessage(data) {
127
+ try {
128
+ const msg = JSON.parse(data.toString());
129
+ if (msg.type === 'request') this._handleHttpRequest(msg);
130
+ else if (msg.type === 'ws-open') this._handleWsOpen(msg);
131
+ else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
132
+ else if (msg.type === 'ws-close') this._handleWsClose(msg);
133
+ // pong: ignore
134
+ } catch (err) {
135
+ this.logger?.error('Failed to parse tunnel message: %s', err.message);
136
+ }
137
+ }
138
+
139
+ // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
140
+ _handleHttpRequest(msg) {
141
+ const { requestId, method, path, headers } = msg;
142
+ const SKIP = new Set(['transfer-encoding','connection','keep-alive',
143
+ 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
144
+ const safeHeaders = Object.fromEntries(
145
+ Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
146
+ );
147
+
148
+ const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
149
+ if (this.internalTunnelSecret) {
150
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
151
+ }
152
+
153
+ const req = httpRequest({
154
+ host: '127.0.0.1', port: this.localPort,
155
+ method, path: path || '/',
156
+ headers: reqHeaders,
157
+ }, (res) => {
158
+ const contentType = String(res.headers['content-type'] ?? '');
159
+ const isSSE = contentType.includes('text/event-stream');
160
+ const chunks = [];
161
+
162
+ if (isSSE) {
163
+ // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
164
+ // 避免 SSE 永不 end 导致隧道服务器超时返回 504
165
+ let sseSent = false;
166
+ const sseTimer = setTimeout(() => {
167
+ if (sseSent) return;
168
+ sseSent = true;
169
+ const respHeaders = Object.fromEntries(
170
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
171
+ );
172
+ this._sendMessage({
173
+ type: 'response', requestId,
174
+ statusCode: res.statusCode, headers: respHeaders,
175
+ body: Buffer.concat(chunks).toString('base64'),
176
+ });
177
+ res.destroy();
178
+ }, 500);
179
+
180
+ res.on('data', (c) => {
181
+ if (sseSent) return;
182
+ chunks.push(c);
183
+ // 收到初始数据后立即发送(不等超时)
184
+ if (chunks.length >= 2) {
185
+ clearTimeout(sseTimer);
186
+ sseSent = true;
187
+ const respHeaders = Object.fromEntries(
188
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
189
+ );
190
+ this._sendMessage({
191
+ type: 'response', requestId,
192
+ statusCode: res.statusCode, headers: respHeaders,
193
+ body: Buffer.concat(chunks).toString('base64'),
194
+ });
195
+ res.destroy();
196
+ }
197
+ });
198
+ res.on('error', () => {
199
+ if (!sseSent) {
200
+ clearTimeout(sseTimer);
201
+ this._sendMessage({
202
+ type: 'response', requestId, statusCode: 502,
203
+ headers: { 'content-type': 'text/plain' },
204
+ body: Buffer.from('Response Error').toString('base64'),
205
+ });
206
+ }
207
+ });
208
+ res.on('end', () => {
209
+ if (!sseSent) {
210
+ clearTimeout(sseTimer);
211
+ sseSent = true;
212
+ const respHeaders = Object.fromEntries(
213
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
214
+ );
215
+ this._sendMessage({
216
+ type: 'response', requestId,
217
+ statusCode: res.statusCode, headers: respHeaders,
218
+ body: Buffer.concat(chunks).toString('base64'),
219
+ });
220
+ }
221
+ });
222
+ return;
223
+ }
224
+
225
+ let responded = false;
226
+ const sendResponse = (statusCode, headers, body) => {
227
+ if (responded) return;
228
+ responded = true;
229
+ this._sendMessage({
230
+ type: 'response', requestId, statusCode, headers,
231
+ body: Buffer.from(body).toString('base64'),
232
+ });
233
+ };
234
+
235
+ let total = 0;
236
+ const cleanPath = String(path || '').split('?')[0];
237
+ // session.list 会在 end 后剥离 contextHeaders 等大字段,跳过 data 阶段的大小检查
238
+ const skipSizeCheck = cleanPath === '/api/session.list';
239
+ res.on('data', (c) => {
240
+ chunks.push(c);
241
+ total += c.length;
242
+ if (!skipSizeCheck && total > MAX_RESPONSE_BYTES) {
243
+ sendResponse(502, { 'content-type': 'text/plain' },
244
+ `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
245
+ res.destroy();
246
+ }
247
+ });
248
+ res.on('error', () => {
249
+ sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
250
+ });
251
+ res.on('end', () => {
252
+ if (responded) return;
253
+ const respHeaders = Object.fromEntries(
254
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
255
+ );
256
+ let bodyBuf = Buffer.concat(chunks);
257
+
258
+ // 剥离 contextHeaders contextTimeline 这两个投影字段包含每轮完整
259
+ // 系统提示+工具定义(4.8MB+),但没有任何客户端 UI 读取它们。
260
+ // session.list: 数百会话 × contextHeaders = 60MB+
261
+ // session.history: 单会话 contextHeaders = 4.8MB,占响应 80%+
262
+ // 剥离后 session.history 1.2MB gzip 降到 ~150KB(8x 减少)
263
+ if ((cleanPath === '/api/session.list' || cleanPath === '/api/session.history') && res.statusCode === 200) {
264
+ try {
265
+ const json = JSON.parse(bodyBuf.toString('utf8'));
266
+ if (json?.result?.ok) {
267
+ const v = json.result.value;
268
+ // session.list: items[].projections.values
269
+ if (v?.items) {
270
+ for (const item of v.items) {
271
+ const proj = item?.projections?.values;
272
+ if (proj) {
273
+ delete proj.contextHeaders;
274
+ delete proj.contextTimeline;
275
+ }
276
+ }
277
+ }
278
+ // session.history: projections.values
279
+ if (v?.projections?.values) {
280
+ delete v.projections.values.contextHeaders;
281
+ delete v.projections.values.contextTimeline;
282
+ }
283
+ bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
284
+ respHeaders['content-length'] = String(bodyBuf.length);
285
+ }
286
+ } catch {} // 解析失败则原样发送
287
+ }
288
+
289
+ // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
290
+ const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
291
+ const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
292
+ const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
293
+ if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
294
+ gzipAsync(bodyBuf).then((zipped) => {
295
+ respHeaders['content-encoding'] = 'gzip';
296
+ respHeaders['content-length'] = String(zipped.length);
297
+ sendResponse(res.statusCode, respHeaders, zipped);
298
+ }).catch(() => {
299
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
300
+ });
301
+ return;
302
+ }
303
+
304
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
305
+ });
306
+ });
307
+ req.on('error', err => {
308
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
309
+ headers: { 'content-type': 'text/plain' },
310
+ body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
311
+ });
312
+ if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
313
+ req.end();
314
+ }
315
+
316
+ // ── WebSocket 升级代理 ────────────────────────────────────────────────────
317
+ // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
318
+ _handleWsOpen(msg) {
319
+ const { wsId, path, headers } = msg;
320
+
321
+ const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
322
+ this.localWsSockets.set(wsId, sock);
323
+
324
+ // 构造 HTTP Upgrade 请求
325
+ const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
326
+ delete reqHeaders['proxy-connection'];
327
+ delete reqHeaders['proxy-authorization'];
328
+ if (this.internalTunnelSecret) {
329
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
330
+ }
331
+
332
+ const lines = [`GET ${path || '/'} HTTP/1.1`];
333
+ for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
334
+ lines.push('', '');
335
+ sock.write(lines.join('\r\n'));
336
+
337
+ let headerBuf = '';
338
+ let upgraded = false;
339
+
340
+ sock.on('data', (chunk) => {
341
+ if (upgraded) {
342
+ this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
343
+ return;
344
+ }
345
+ headerBuf += chunk.toString('binary');
346
+ const sep = headerBuf.indexOf('\r\n\r\n');
347
+ if (sep === -1) return;
348
+
349
+ upgraded = true;
350
+ const replyHeaders = {};
351
+ const headerLines = headerBuf.slice(0, sep).split('\r\n');
352
+ for (let i = 1; i < headerLines.length; i++) {
353
+ const ci = headerLines[i].indexOf(':');
354
+ if (ci > 0) {
355
+ replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
356
+ headerLines[i].slice(ci + 1).trim();
357
+ }
358
+ }
359
+ this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
360
+
361
+ // 握手后紧跟的帧数据
362
+ const rest = headerBuf.slice(sep + 4);
363
+ if (rest.length > 0) {
364
+ this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
365
+ }
366
+ });
367
+
368
+ sock.on('close', () => {
369
+ this._sendMessage({ type: 'ws-close', wsId });
370
+ this.localWsSockets.delete(wsId);
371
+ });
372
+ sock.on('error', (err) => {
373
+ this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
374
+ this._sendMessage({ type: 'ws-close', wsId });
375
+ this.localWsSockets.delete(wsId);
376
+ });
377
+ }
378
+
379
+ _handleWsFrame(msg) {
380
+ const sock = this.localWsSockets.get(msg.wsId);
381
+ if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
382
+ }
383
+
384
+ _handleWsClose(msg) {
385
+ const sock = this.localWsSockets.get(msg.wsId);
386
+ if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
387
+ }
388
+
389
+ _cleanupLocalWs() {
390
+ for (const [, sock] of this.localWsSockets) sock.destroy();
391
+ this.localWsSockets.clear();
392
+ }
393
+
394
+ // ── 工具方法 ──────────────────────────────────────────────────────────────
395
+ _sendMessage(msg) {
396
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
397
+ }
398
+
399
+ _startHeartbeat() {
400
+ this._stopHeartbeat();
401
+ this.heartbeatTimer = setInterval(() => {
402
+ if (this.connected) this._sendMessage({ type: 'ping' });
403
+ }, HEARTBEAT_INTERVAL);
404
+ }
405
+
406
+ _stopHeartbeat() {
407
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
408
+ }
409
+
410
+ _scheduleReconnect() {
411
+ if (this.signal?.aborted || this.disconnecting) return;
412
+ if (this.reconnectTimer) return;
413
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
414
+ this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
415
+ return;
416
+ }
417
+ this.reconnectAttempts++;
418
+ const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
419
+ this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
420
+ this.reconnectTimer = setTimeout(() => {
421
+ this.reconnectTimer = null;
422
+ this.connect().catch(() => {});
423
+ }, delay);
424
+ }
425
+
426
+ _setState(phase, detail) {
427
+ if (this.onStateChange) this.onStateChange({ phase, detail });
428
+ }
429
+
430
+ disconnect() {
431
+ this.disconnecting = true;
432
+ this._stopHeartbeat();
433
+ this._cleanupLocalWs();
434
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
435
+ if (this.ws) { this.ws.close(); this.ws = null; }
436
+ this.connected = false;
437
+ this.publicUrl = null;
438
+ this.logger?.info('Tunnel disconnected');
439
+ }
440
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.0",
3
+ "version": "2.10.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.0 远程锁屏/解锁流程修复】\n• 🔐 修复解锁后无限锁屏循环:访问会话失效(DSH 重启/过期)时自动跳回绿色登录页重新登录;修复锁屏状态下无法解锁(401);修复关闭访问认证后锁屏失效 / 仍要求密码;远程首次进入设置页即显示全屏锁屏;目录选择器解锁不再卡死\n• 🆕 新增「管理保护」独立开关(与访问认证解耦);面板显示 DSH 宿主版本\n• 🧹 统一 adminToken 缓存,清理死代码与重复实现",
5
+ "releaseNotes": "【v2.10.2】\n• 🆕 外部已部署隧道登记(issue #25):自行部署 cloudflared(Docker 等)可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理\n• 🐞 修复会话列表/历史加载问题:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {