@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 +39 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +103 -1
- package/client/index.js +83 -0
- package/lib/bridge-rpc-constants.js +1 -0
- package/lib/bridge-rpc.js +10 -0
- package/lib/feishu/node.js +10 -4
- package/lib/index.js +126 -14
- package/lib/platform/conversation-bridge.js +8 -3
- package/lib/platform/message-split.js +39 -1
- package/lib/qq/node.js +539 -532
- package/lib/session-strip.js +57 -0
- package/lib/telegram/node.js +0 -27
- package/lib/tunnel-client.mjs +419 -426
- package/lib/wechat/gateway.js +17 -4
- package/package.json +2 -2
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;
|
package/lib/feishu/node.js
CHANGED
|
@@ -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
|
-
//
|
|
186
|
-
|
|
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
|
|
394
|
-
|
|
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
|
-
//
|
|
435
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
711
|
+
// loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
|
|
712
|
+
// 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
|
|
713
|
+
// 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
|
|
647
714
|
allowedOrigins: () => {
|
|
648
|
-
const origins = [
|
|
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
|
-
|
|
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);
|