@wenbin_wb/dsh-bridge 2.10.11 → 2.10.13
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 +29 -6
- package/client/client.js +67 -44
- package/client/index.js +69 -47
- package/client/picker-yield.js +77 -0
- package/lib/cloudflared-manager.mjs +439 -11
- package/lib/index.js +42 -18
- package/lib/restart-helper.mjs +27 -9
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,15 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## [v2.10.
|
|
7
|
+
## [v2.10.13] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
> 本版为可靠性与兼容性加固:修复 cloudflared 连接器"假死"(进程存活但边缘连接全掉)造成的公网隧道静默失效(新增运行时健康探针与输出落盘);跟随 DSH 0.1.5 的官方目录选择器,让本机访问不再误弹插件的远程抽屉;并修复添加工作区弹窗的状态泄漏卡死。
|
|
10
|
+
|
|
11
|
+
### ✨ 新功能
|
|
12
|
+
|
|
13
|
+
- **cloudflared 运行时健康探针**:解析 cloudflared 自报 metrics 地址并每 30s 探活官方 `/ready`(判据与上游一致:HTTP 200 且 `readyConnections > 0`)。连续 3 次失败(约 90s)→ 面板状态可见降级「⚠️ 连接器已无健康边缘连接」;连续 10 次(约 5min)→ 判定假死、终止进程并走既有自愈链路强制重建。重建链路(含重启后握手失败)**永不进入终态**——网络恢复后会自动自愈,不再需要人工干预。
|
|
14
|
+
- **cloudflared 输出落盘**:子进程输出写入 `~/.dsh/dsh-bridge/cloudflared.log`,超 5MB 自动轮转为 `.1`;隧道 Token 落盘前强制脱敏(含跨输出块切分、以及 Token 前缀残留等边界,确保任何 Token 片段都不落盘)。
|
|
15
|
+
|
|
16
|
+
### 🐞 修复
|
|
17
|
+
|
|
18
|
+
- **公网入口静默失效数天无人发现**(现象:HTTP 530 + `error code: 1033`):旧逻辑只监听子进程退出,而假死连接器既不退出也不报错,故永不触发自愈。探针上线后最迟约 5 分钟即可自动重建。
|
|
19
|
+
- **本机点「添加工作区」误弹插件的远程抽屉**(issue #28 第 2 条):DSH 0.1.5 起自带官方目录选择器(`dsh-host-directory-picker-auto` 按宿主能力分发 native / browse),且注册到与插件完全相同的 Slot;DSH 的 slots 是 shadow 语义(动态注册的插件天然覆盖内置实现),于是本机 `127.0.0.1:3080` 也被插件的远程目录选择器接管。现改为「本机访问 且 官方 picker 可用」时**不注册**该 Slot,让位给 DSH 官方——macOS / Windows 走系统原生文件夹对话框,无桌面环境的 Linux 走官方网页选择器;**旧版 DSH 与远程 / 移动访问仍由插件接管**,保留管理密码解锁与 `local_only` 目录策略。
|
|
20
|
+
- **添加工作区弹窗点过「已注册工作区」后永久卡死**(PR #42):`switchToWorkspace()` 只置位 `isSubmitting` 而从不复位,同一弹窗实例的 `loadDirectory()` / `doSubmit()` 守卫让弹窗彻底无响应(点目录不进目录、点添加无反应且按钮停在「正在添加并切换…」);改为与 `doSubmit()` 对称的 `try/finally` 复位,`render()` 抛错与将来新增的提前 return 都不会再泄漏。
|
|
21
|
+
- **探针自身异常不得拖垮宿主**:畸形 metrics 地址、日志通道自身抛错等任何探针路径异常都不会变成未处理 rejection 结束 DSH 主进程(独立子进程实验验证)。
|
|
22
|
+
- **新增 18 条探针回归测试**(含防误杀正常重连连接器、防终态、脱敏泄漏/吞字符、跨 chunk 脱敏、崩溃回归等)。
|
|
23
|
+
- **新增 15 条回归测试**:12 条目录选择器让位(让位判定真值表、官方 picker 探测、Slot 注册顺序结构断言、产物同步)+ 3 条添加工作区弹窗 `isSubmitting` 复位与产物同步。
|
|
24
|
+
|
|
25
|
+
> ⚠️ 生效说明:本版需要重启一次 dsh(`systemctl --user restart dsh-web.service` 或面板「重启 DSH」)才加载新代码;重启会短暂中断当前会话与公网隧道(数十秒)。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## [v2.10.12] - 2026-09-16
|
|
30
|
+
|
|
31
|
+
> 本版汇总 v2.10.11 的修复内容,并含 4 处可靠性加固(systemctl 退出码判定、单元名解析、助手失败日志、注入分段判重)。
|
|
8
32
|
|
|
9
33
|
### 🐞 修复
|
|
10
34
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
- **「重启 DSH」按钮在 systemd 托管下点了没用,DSH 再也没起来(只能手动重启),且不留任何日志**:桥原实现是不识别托管器的「自派生子进程 + `process.exit(0)`」。在 systemd 单元(如 `dsh-web.service`,用户在 user slice 下)里这必然失败:`Restart=on-failure` 不认干净退出的 code 0(systemd 不会拉起),而默认 `KillMode=control-group` 会在主进程退出时把刚派生的子进程一并杀掉;旧实现还把子进程 stdio 设成 `ignore`,所以连失败原因都看不到。现按托管方式分流:从 `/proc/self/cgroup` 取出**最内层** systemd 单元名 → 交给 `systemctl [--user] restart --no-block <unit>`(stop+start 由 systemd 负责,新进程仍在正确 cgroup 内,并尊重单元自身的 Restart 策略);`DSH_DAEMON` / `PM2_HOME` 仍走守护进程退出;无托管器时派生独立助手 `lib/restart-helper.mjs`,**等旧进程真正退出、端口释放后**才拉起新 DSH,并在最长 120s 内确认端口可连接,全过程写入 `~/.dsh/dsh-bridge/restart.log`(子进程输出另存 `restart-child.log`)。前端在宿主明确返回 `ok:false`(例如 `systemctl` 与助手都派生失败)时**直接报错**,不再假装「正在重连」让用户面对永远转圈。注意:systemd 托管的环境需先手动重启一次让本修复生效,之后按钮即可正常使用。
|
|
35
|
+
- **iOS 16 / 旧 Safari 打不开 DSH**(红屏 `Failed to load plugins … Can't find variable: Iterator`):代理注入 HTML 时补齐 `Promise.withResolvers` 与 `Iterator` 垫片(Safari 分别是 17.4 / 18.4 才有),只在缺失时安装,其它浏览器零改动。
|
|
36
|
+
- **鸿蒙(HuaweiBrowser / ArkWeb)文档预览一直显示「文件资源服务不可用。」**:该内核解析 `dsh-resource://` 地址取不到 hostname,新增 URL 兼容层兜底;引擎正常时不介入。
|
|
37
|
+
- **移动端顶栏压住全屏面板顶部 52px(#41)**:改为直接位移面板容器本身,并让移动端断点与宿主判据对齐(`≤767px`)。
|
|
38
|
+
- **「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来**:改为识别 systemd 单元并交给 `systemctl restart`,按退出码判定成败;失败时保持运行并提示手动重启命令。
|
|
16
39
|
|
|
17
40
|
---
|
|
18
41
|
|
package/client/client.js
CHANGED
|
@@ -1067,6 +1067,21 @@ async function unlockAdmin(rpcCall, password) {
|
|
|
1067
1067
|
}
|
|
1068
1068
|
}
|
|
1069
1069
|
|
|
1070
|
+
// client/picker-yield.js
|
|
1071
|
+
var OFFICIAL_WORKSPACE_SEAT = "uiWorkspace";
|
|
1072
|
+
function hasOfficialDirectoryPicker(ctx) {
|
|
1073
|
+
try {
|
|
1074
|
+
if (typeof ctx?.get !== "function") return false;
|
|
1075
|
+
const seat = ctx.get(OFFICIAL_WORKSPACE_SEAT);
|
|
1076
|
+
return typeof seat?.pickDirectory === "function";
|
|
1077
|
+
} catch {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function shouldYieldToOfficialPicker(facts) {
|
|
1082
|
+
return Boolean(facts?.local) && Boolean(facts?.officialPicker);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1070
1085
|
// lib/bridge-rpc-constants.js
|
|
1071
1086
|
var BRIDGE_RPC_CHANNEL = "/dsh-bridge";
|
|
1072
1087
|
var BRIDGE_ENDPOINTS = {
|
|
@@ -5941,60 +5956,64 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
|
|
|
5941
5956
|
async function switchToWorkspace(wsId, wsPath) {
|
|
5942
5957
|
if (isSubmitting) return;
|
|
5943
5958
|
isSubmitting = true;
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
let switched = false;
|
|
5954
|
-
if (clientCtx?.workspaces?.startSession && wsId) {
|
|
5955
|
-
try {
|
|
5956
|
-
clientCtx.workspaces.startSession(wsId);
|
|
5957
|
-
switched = true;
|
|
5958
|
-
} catch (e) {
|
|
5959
|
-
console.warn("[dsh-bridge] startSession failed:", e);
|
|
5959
|
+
try {
|
|
5960
|
+
statusMessage = `\u6B63\u5728\u5207\u6362\u5DE5\u4F5C\u533A\u2026`;
|
|
5961
|
+
isErrorMessage = false;
|
|
5962
|
+
render();
|
|
5963
|
+
if (typeof onPicked === "function" && wsPath) {
|
|
5964
|
+
try {
|
|
5965
|
+
onPicked(wsPath);
|
|
5966
|
+
} catch (e) {
|
|
5967
|
+
}
|
|
5960
5968
|
}
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
switched = true;
|
|
5969
|
-
}
|
|
5969
|
+
let switched = false;
|
|
5970
|
+
if (clientCtx?.workspaces?.startSession && wsId) {
|
|
5971
|
+
try {
|
|
5972
|
+
clientCtx.workspaces.startSession(wsId);
|
|
5973
|
+
switched = true;
|
|
5974
|
+
} catch (e) {
|
|
5975
|
+
console.warn("[dsh-bridge] startSession failed:", e);
|
|
5970
5976
|
}
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
if (
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
+
}
|
|
5978
|
+
if (!switched && wsPath) {
|
|
5979
|
+
try {
|
|
5980
|
+
if (clientCtx?.workspaces?.create) {
|
|
5981
|
+
const ws = await clientCtx.workspaces.create({ path: wsPath });
|
|
5982
|
+
if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
|
|
5983
|
+
clientCtx.workspaces.startSession(ws.workspaceId);
|
|
5977
5984
|
switched = true;
|
|
5978
|
-
} catch (e) {
|
|
5979
5985
|
}
|
|
5980
5986
|
}
|
|
5981
|
-
if (!switched
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5987
|
+
if (!switched) {
|
|
5988
|
+
const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
|
|
5989
|
+
const res = raw?.value || raw;
|
|
5990
|
+
if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
|
|
5991
|
+
try {
|
|
5992
|
+
clientCtx.workspaces.startSession(res.workspaceId);
|
|
5993
|
+
switched = true;
|
|
5994
|
+
} catch (e) {
|
|
5995
|
+
}
|
|
5996
|
+
}
|
|
5997
|
+
if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
|
|
5998
|
+
try {
|
|
5999
|
+
clientCtx.sessions.open(res.sessionId);
|
|
6000
|
+
switched = true;
|
|
6001
|
+
} catch (e) {
|
|
6002
|
+
}
|
|
5986
6003
|
}
|
|
5987
6004
|
}
|
|
6005
|
+
} catch (e) {
|
|
5988
6006
|
}
|
|
5989
|
-
} catch (e) {
|
|
5990
6007
|
}
|
|
6008
|
+
statusMessage = `\u2713 \u5DF2\u5207\u6362\u81F3\u5DE5\u4F5C\u533A\uFF01`;
|
|
6009
|
+
render();
|
|
6010
|
+
setTimeout(() => {
|
|
6011
|
+
closeModal();
|
|
6012
|
+
document.body.classList.remove("dsh-drawer-open");
|
|
6013
|
+
}, 400);
|
|
6014
|
+
} finally {
|
|
6015
|
+
isSubmitting = false;
|
|
5991
6016
|
}
|
|
5992
|
-
statusMessage = `\u2713 \u5DF2\u5207\u6362\u81F3\u5DE5\u4F5C\u533A\uFF01`;
|
|
5993
|
-
render();
|
|
5994
|
-
setTimeout(() => {
|
|
5995
|
-
closeModal();
|
|
5996
|
-
document.body.classList.remove("dsh-drawer-open");
|
|
5997
|
-
}, 400);
|
|
5998
6017
|
}
|
|
5999
6018
|
async function loadDirectory(targetPath) {
|
|
6000
6019
|
if (isSubmitting) return;
|
|
@@ -6388,6 +6407,10 @@ function apply(ctx) {
|
|
|
6388
6407
|
ctx.slots.inject(
|
|
6389
6408
|
"conversation.hero.workspace.directoryFlow",
|
|
6390
6409
|
() => ctx.slots.inject("sidebar.workspaces.directoryFlow", function* () {
|
|
6410
|
+
if (shouldYieldToOfficialPicker({
|
|
6411
|
+
local: isLocalEnvironment(),
|
|
6412
|
+
officialPicker: hasOfficialDirectoryPicker(ctx)
|
|
6413
|
+
})) return;
|
|
6391
6414
|
yield ctx.slots.register(
|
|
6392
6415
|
{
|
|
6393
6416
|
name: "conversation.hero.workspace.directoryFlow",
|
package/client/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
queuePendingOperation, unlockAdmin, onUnlocked,
|
|
5
5
|
fetchLoopbackTokenOnce,
|
|
6
6
|
} from './unlock-manager.js'
|
|
7
|
+
import { hasOfficialDirectoryPicker, shouldYieldToOfficialPicker } from './picker-yield.js'
|
|
7
8
|
// dsh-bridge 客户端插件:设置页「远程访问」面板
|
|
8
9
|
|
|
9
10
|
// 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
|
|
@@ -4545,60 +4546,68 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
|
|
|
4545
4546
|
async function switchToWorkspace(wsId, wsPath) {
|
|
4546
4547
|
if (isSubmitting) return;
|
|
4547
4548
|
isSubmitting = true;
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
if (typeof onPicked === 'function' && wsPath) {
|
|
4553
|
-
try {
|
|
4554
|
-
onPicked(wsPath);
|
|
4555
|
-
} catch (e) {}
|
|
4556
|
-
}
|
|
4549
|
+
try {
|
|
4550
|
+
statusMessage = `正在切换工作区…`;
|
|
4551
|
+
isErrorMessage = false;
|
|
4552
|
+
render();
|
|
4557
4553
|
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
switched = true;
|
|
4563
|
-
} catch (e) {
|
|
4564
|
-
console.warn('[dsh-bridge] startSession failed:', e);
|
|
4554
|
+
if (typeof onPicked === 'function' && wsPath) {
|
|
4555
|
+
try {
|
|
4556
|
+
onPicked(wsPath);
|
|
4557
|
+
} catch (e) {}
|
|
4565
4558
|
}
|
|
4566
|
-
}
|
|
4567
4559
|
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
}
|
|
4560
|
+
let switched = false;
|
|
4561
|
+
if (clientCtx?.workspaces?.startSession && wsId) {
|
|
4562
|
+
try {
|
|
4563
|
+
clientCtx.workspaces.startSession(wsId);
|
|
4564
|
+
switched = true;
|
|
4565
|
+
} catch (e) {
|
|
4566
|
+
console.warn('[dsh-bridge] startSession failed:', e);
|
|
4576
4567
|
}
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
if (!switched && wsPath) {
|
|
4571
|
+
try {
|
|
4572
|
+
if (clientCtx?.workspaces?.create) {
|
|
4573
|
+
const ws = await clientCtx.workspaces.create({ path: wsPath });
|
|
4574
|
+
if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
|
|
4575
|
+
clientCtx.workspaces.startSession(ws.workspaceId);
|
|
4583
4576
|
switched = true;
|
|
4584
|
-
}
|
|
4577
|
+
}
|
|
4585
4578
|
}
|
|
4586
|
-
if (!switched
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4579
|
+
if (!switched) {
|
|
4580
|
+
const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
|
|
4581
|
+
const res = raw?.value || raw;
|
|
4582
|
+
if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
|
|
4583
|
+
try {
|
|
4584
|
+
clientCtx.workspaces.startSession(res.workspaceId);
|
|
4585
|
+
switched = true;
|
|
4586
|
+
} catch (e) {}
|
|
4587
|
+
}
|
|
4588
|
+
if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
|
|
4589
|
+
try {
|
|
4590
|
+
clientCtx.sessions.open(res.sessionId);
|
|
4591
|
+
switched = true;
|
|
4592
|
+
} catch (e) {}
|
|
4593
|
+
}
|
|
4591
4594
|
}
|
|
4592
|
-
}
|
|
4593
|
-
}
|
|
4594
|
-
}
|
|
4595
|
+
} catch (e) {}
|
|
4596
|
+
}
|
|
4595
4597
|
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4598
|
+
statusMessage = `✓ 已切换至工作区!`;
|
|
4599
|
+
render();
|
|
4600
|
+
setTimeout(() => {
|
|
4601
|
+
closeModal();
|
|
4602
|
+
document.body.classList.remove('dsh-drawer-open');
|
|
4603
|
+
}, 400);
|
|
4604
|
+
} finally {
|
|
4605
|
+
// 与 doSubmit 对称:本函数此前只置位、从不复位,导致点过一次「已注册工作区」后,
|
|
4606
|
+
// 同一弹窗实例的 isSubmitting 永久为 true,loadDirectory/doSubmit 的守卫
|
|
4607
|
+
// 让整个弹窗彻底无响应(点目录、点添加都没反应)。用 finally 兜住所有路径,
|
|
4608
|
+
// 包括 render() 抛错以及将来新增的提前 return。
|
|
4609
|
+
isSubmitting = false;
|
|
4610
|
+
}
|
|
4602
4611
|
}
|
|
4603
4612
|
|
|
4604
4613
|
async function loadDirectory(targetPath) {
|
|
@@ -5041,9 +5050,22 @@ function apply(ctx) {
|
|
|
5041
5050
|
|
|
5042
5051
|
const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
|
|
5043
5052
|
|
|
5044
|
-
// 注册至 DSH 原生目录选择 Slot
|
|
5053
|
+
// 注册至 DSH 原生目录选择 Slot(priority: -10 覆盖原生 Electron 选择器,在远程/移动网页端生效)
|
|
5054
|
+
//
|
|
5055
|
+
// 但 DSH 0.1.5 起自带官方目录选择器(@deepseek-ai/dsh-host-directory-picker-auto
|
|
5056
|
+
// 按宿主能力分发 native / browse),且注册到完全相同的两个 Slot。DSH 的 slots 是
|
|
5057
|
+
// shadow 语义(动态注册的插件天然覆盖内置实现,详见 client/picker-yield.js),
|
|
5058
|
+
// 因此本机 + 官方 picker 可用时必须「不注册」才能让位 —— 用低优先级注册是无效的,
|
|
5059
|
+
// 否则本机 127.0.0.1 点「添加工作区」弹的是插件的远程抽屉(issue #28 第 2 条)。
|
|
5060
|
+
// 旧版 DSH(无官方 picker)与远程/移动访问继续由插件接管,后者保留管理密码解锁
|
|
5061
|
+
// 与 local_only 目录策略(换成官方 picker 会绕过插件的访问限制)。
|
|
5045
5062
|
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
|
5046
5063
|
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
|
5064
|
+
if (shouldYieldToOfficialPicker({
|
|
5065
|
+
local: isLocalEnvironment(),
|
|
5066
|
+
officialPicker: hasOfficialDirectoryPicker(ctx),
|
|
5067
|
+
})) return;
|
|
5068
|
+
|
|
5047
5069
|
yield ctx.slots.register(
|
|
5048
5070
|
{
|
|
5049
5071
|
name: 'conversation.hero.workspace.directoryFlow',
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// DSH 官方目录选择器的「让位」判定(issue #28 第 2 条)
|
|
2
|
+
//
|
|
3
|
+
// 背景:DSH 0.1.5 起自带官方目录选择器,且注册到与 dsh-bridge 完全相同的两个 Slot:
|
|
4
|
+
// conversation.hero.workspace.directoryFlow
|
|
5
|
+
// sidebar.workspaces.directoryFlow
|
|
6
|
+
// 后端由 @deepseek-ai/dsh-host-directory-picker-auto 按宿主事实分发:非回环绑定 /
|
|
7
|
+
// SSH / Linux 无 zenity·kdialog 等图形 chooser → browse(网页树形选择器),
|
|
8
|
+
// macOS·Windows 或 Linux 有 chooser → native(系统对话框)。
|
|
9
|
+
//
|
|
10
|
+
// 而 DSH 的 slots 是 shadow 语义:@deepseek-ai/dsh-cordis-client-runner 的注释明确
|
|
11
|
+
// "Page-local shadowing rank. A later registration receives a lower priority."
|
|
12
|
+
// "a dynamically registered entry is assigned a lower priority than the shipped
|
|
13
|
+
// one, which makes it the winner"
|
|
14
|
+
// 即数值越小越优先、后注册者胜。动态加载的 dsh-bridge 因此天然覆盖内置 picker,
|
|
15
|
+
// 叠加本插件显式写的 `priority: -10`,连本机(127.0.0.1 / Electron)也被插件自己的
|
|
16
|
+
// 远程目录选择器接管:点「添加工作区」弹的是插件的远程抽屉而非 DSH 官方选择器。
|
|
17
|
+
//
|
|
18
|
+
// 让位条件(两者同时成立才让位):
|
|
19
|
+
// 1. 本机访问(isLocalEnvironment())—— 本机有完整权限,官方 picker 直接可用;
|
|
20
|
+
// 2. DSH 已自带官方 picker —— 旧版 DSH 没有,必须继续由插件兜底,
|
|
21
|
+
// 否则本机点「添加工作区」将彻底没有反应。
|
|
22
|
+
// 远程 / 移动访问一律继续由插件接管:那里需要管理密码解锁与 local_only 目录策略,
|
|
23
|
+
// 不能换成官方 picker(会绕过插件的访问限制)。
|
|
24
|
+
//
|
|
25
|
+
// 为什么探测用 ctx.get() 而不是 ctx.uiWorkspace:
|
|
26
|
+
// cordis 本体对**未在插件 inject 中声明**的服务做属性访问会直接抛错:
|
|
27
|
+
// cannot get property "uiWorkspace" without inject (cordis/lib/index.js)
|
|
28
|
+
// —— dsh-bridge 的 client half 是静态 client 插件,其 ctx 是真 cordis Context
|
|
29
|
+
// (不是动态包的 runner guard facade),所以拦在这里的是 cordis 本体的规则。
|
|
30
|
+
// 而 `ctx.get(name)` 是允许的「可选查找」,不要求声明。
|
|
31
|
+
// 又不能把 uiWorkspace 写进 inject:旧版 DSH(无该 seat)会因此把整个插件 park 掉
|
|
32
|
+
// (provider 缺失即不 apply),所以只能用 ctx.get() 探测。
|
|
33
|
+
//
|
|
34
|
+
// ⚠️ 已知时序依赖(独立验收记录,真机 6/6 冷启动实测未触发):
|
|
35
|
+
// cordis 的 ctx.get() 在 provider fiber 尚未进入 ACTIVE 时会返回 undefined。理论上若
|
|
36
|
+
// 本判定早于 ui-workspace 插件 apply 完成,就会把「有官方 picker」误判为「无」而仍然
|
|
37
|
+
// 注册,让位失效。当前稳定是因为 dsh-bridge 自身 inject 的 workspaces / sessions 由
|
|
38
|
+
// roster 中排在 ui-workspace **之后**的插件提供,插件真正激活时 ui-workspace 早已
|
|
39
|
+
// apply 完成 —— 这是对 roster 顺序的**隐式耦合,不是显式保证**。若将来调整 roster
|
|
40
|
+
// 顺序或去掉 workspaces 依赖,需重新评估(加固思路:注册后等 uiWorkspace 可用时
|
|
41
|
+
// 再撤销自己的注册)。
|
|
42
|
+
|
|
43
|
+
/** 官方 workspace seat 的服务名(DSH 0.1.5 起提供 pickDirectory) */
|
|
44
|
+
export const OFFICIAL_WORKSPACE_SEAT = 'uiWorkspace';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 探测 DSH 是否已提供官方目录选择器。
|
|
48
|
+
*
|
|
49
|
+
* 用 `ctx.get()` 做可选查找:seat 不存在(旧版 DSH)、宿主未暴露 pickDirectory、
|
|
50
|
+
* 查询或属性读取抛错时一律返回 false —— 探测本身绝不外抛影响插件加载。
|
|
51
|
+
*
|
|
52
|
+
* @param {{ get?: (name: string) => unknown }} [ctx] cordis 客户端 context
|
|
53
|
+
* @returns {boolean} 官方 picker 是否可用
|
|
54
|
+
*/
|
|
55
|
+
export function hasOfficialDirectoryPicker(ctx) {
|
|
56
|
+
try {
|
|
57
|
+
if (typeof ctx?.get !== 'function') return false;
|
|
58
|
+
const seat = ctx.get(OFFICIAL_WORKSPACE_SEAT);
|
|
59
|
+
// typeof 也放在 try 内:seat 可能是带异常 getter 的对象或 Proxy,
|
|
60
|
+
// 读 pickDirectory 本身就可能抛错。
|
|
61
|
+
return typeof seat?.pickDirectory === 'function';
|
|
62
|
+
} catch {
|
|
63
|
+
// ctx.get 抛错(宿主异常),或属性读取抛错(异常 getter / Proxy):
|
|
64
|
+
// 一律按「无官方 picker」处理,保持插件原有兜底行为,探测绝不外抛。
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 是否应让位给 DSH 官方目录选择器。
|
|
71
|
+
*
|
|
72
|
+
* @param {{ local?: boolean, officialPicker?: boolean }} [facts]
|
|
73
|
+
* @returns {boolean} true = 不注册插件选择器,本机交给 DSH 官方处理
|
|
74
|
+
*/
|
|
75
|
+
export function shouldYieldToOfficialPicker(facts) {
|
|
76
|
+
return Boolean(facts?.local) && Boolean(facts?.officialPicker);
|
|
77
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn, execSync } from 'node:child_process';
|
|
2
|
-
import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync, statSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { chmod, stat, unlink, rename } from 'node:fs/promises';
|
|
4
4
|
import { homedir, platform, arch } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { get as httpsGet } from 'node:https';
|
|
9
|
+
import { get as httpGet } from 'node:http';
|
|
9
10
|
|
|
10
11
|
const CLOUDFLARED_VERSION = '2024.10.0';
|
|
11
12
|
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
|
|
@@ -15,6 +16,47 @@ const RETRY_BASE_MS = 5 * 1000; // 自愈退避起点 5s
|
|
|
15
16
|
const RETRY_MAX_MS = 5 * 60 * 1000; // 自愈退避封顶 5min
|
|
16
17
|
const DEFAULT_MAX_RETRIES = 12; // 连续失败超过该次数转为 error,不再无限重试
|
|
17
18
|
|
|
19
|
+
// ── 运行时健康探针 ────────────────────────────────────────────────────────
|
|
20
|
+
// 事故背景:cloudflared 与 Cloudflare 边缘的连接全部掉光后进程仍存活("半死/假死"),
|
|
21
|
+
// 既不退出也不报错,因此只监听 exit 的旧自愈逻辑永远不会触发,隧道会静默失效
|
|
22
|
+
// (现象:公网 530 + 正文 error code: 1033,持续 2 天无人发现)。
|
|
23
|
+
//
|
|
24
|
+
// 关键约束(一手源码 cloudflared 2024.10.0 `tunnelstate/conntracker.go`):/ready 的
|
|
25
|
+
// readyConnections 取自 CountActiveConns(),而 Disconnected / Reconnecting /
|
|
26
|
+
// RegisteringTunnel / Unregistering 全部把 IsConnected 置 false —— 即
|
|
27
|
+
// **正常重连期间 /ready 同样是 503**。因此单次 503 无法区分"连接器假死"与
|
|
28
|
+
// "网络暂时全断、cloudflared 正按自身退避重连"。唯一能区分的是"持续多久",
|
|
29
|
+
// 故采用两级升级,且强制重建永不进入终态(见 _scheduleRestart 的 terminal 选项)。
|
|
30
|
+
const HEALTH_PROBE_INTERVAL_MS = 30 * 1000; // 探活间隔
|
|
31
|
+
const HEALTH_PROBE_TIMEOUT_MS = 5 * 1000; // 单次探活总时限(远程调用防失控)
|
|
32
|
+
const HEALTH_DEGRADED_THRESHOLD = 3; // 连续失败达此数 → 面板可见降级(约 90s 发现)
|
|
33
|
+
const HEALTH_RESTART_THRESHOLD = 10; // 连续失败达此数 → 终止进程强制重建(约 5min)
|
|
34
|
+
|
|
35
|
+
// ── cloudflared 输出落盘 ──────────────────────────────────────────────────
|
|
36
|
+
// 事故时 journal 里查不到任何 cloudflared 日志,导致事后无法定位断连时间点与原因。
|
|
37
|
+
export const CLOUDFLARED_LOG_NAME = 'cloudflared.log';
|
|
38
|
+
const CLOUDFLARED_LOG_MAX_BYTES = 5 * 1024 * 1024; // 超限轮转为 .1(覆盖旧的)
|
|
39
|
+
|
|
40
|
+
// 从 cloudflared 自报日志中解析 metrics 服务地址(运行时健康探针的唯一入口)。
|
|
41
|
+
// 真实输出形如:INF Starting metrics server on 127.0.0.1:41143/metrics
|
|
42
|
+
// 解析不到 → 显式告警并跳过探活(绝不静默降级,也绝不因探测不到而误杀进程)。
|
|
43
|
+
//
|
|
44
|
+
// 必须在这里就把非法地址拦掉:node:http 的 get() 对畸形 URL 会**同步抛错**
|
|
45
|
+
// (ERR_INVALID_URL),异常会穿透探针 Promise 造成未处理 rejection ——
|
|
46
|
+
// Node 默认 unhandled-rejections=throw 会直接结束整个 DSH 主进程,
|
|
47
|
+
// 也就是说"本该保护服务的探针"反而会把服务干掉。端口必须落在 1..65535。
|
|
48
|
+
export function parseMetricsAddress(text) {
|
|
49
|
+
if (!text) return null;
|
|
50
|
+
const m = /Starting metrics server on (\S+?)\/metrics/.exec(text);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const addr = m[1];
|
|
53
|
+
const parts = /^([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})$/.exec(addr);
|
|
54
|
+
if (!parts) return null;
|
|
55
|
+
const port = Number(parts[2]);
|
|
56
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
57
|
+
return addr;
|
|
58
|
+
}
|
|
59
|
+
|
|
18
60
|
// ── 确定性失败特征:cloudflared 因配置/用法错误退出(非网络瞬态)─────────
|
|
19
61
|
// 这类失败重试无意义,识别后直接置 error 让用户看到明确原因,而不是
|
|
20
62
|
// 误判成"意外退出"退避重连 N 次(issue #35 作者建议)。
|
|
@@ -138,10 +180,26 @@ export class CloudflaredManager {
|
|
|
138
180
|
* @param {number} [opts.handshakeTimeoutMs=90000] 等待隧道就绪的握手超时(测试可注入小值)。
|
|
139
181
|
* @param {object} [opts.spawnOptions] 透传给 child_process.spawn 的额外选项(测试注入用,
|
|
140
182
|
* 如 Windows 下需 shell:true 才能运行 .cmd mock;生产不传)。
|
|
183
|
+
* @param {number} [opts.healthProbeIntervalMs=30000] 就绪后运行时探活间隔。
|
|
184
|
+
* @param {number} [opts.healthProbeTimeoutMs=5000] 单次探活总时限。
|
|
185
|
+
* @param {number} [opts.healthDegradedThreshold=3] 连续失败达此数即在面板上显示降级
|
|
186
|
+
* (默认约 90s——这是"发现故障"的时点)。
|
|
187
|
+
* @param {number} [opts.healthRestartThreshold=10] 连续失败达此数才终止进程强制重建
|
|
188
|
+
* (默认约 5min)。必须显著大于 degraded 阈值:/ready 无法区分"假死"与
|
|
189
|
+
* "正在自行重连",只有持续时长能区分,过早重建会误杀本可自愈的连接器。
|
|
190
|
+
* @param {string|null} [opts.logFilePath=null] cloudflared stdout/stderr 落盘路径;
|
|
191
|
+
* null 表示不落盘(默认)。生产由调用方显式传入(见 lib/index.js),
|
|
192
|
+
* 避免库的默认行为在单测里往真实运维日志目录写测试内容。
|
|
193
|
+
* @param {number} [opts.logMaxBytes=5242880] 单文件超限后轮转为 `<logFilePath>.1`。
|
|
141
194
|
*/
|
|
142
195
|
constructor({ port, home, token, hostname, onStateChange, logger,
|
|
143
196
|
binaryPath, retryPolicy, noAutoupdate = true, binaryVersion = CLOUDFLARED_VERSION,
|
|
144
|
-
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnOptions = null
|
|
197
|
+
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnOptions = null,
|
|
198
|
+
healthProbeIntervalMs = HEALTH_PROBE_INTERVAL_MS,
|
|
199
|
+
healthProbeTimeoutMs = HEALTH_PROBE_TIMEOUT_MS,
|
|
200
|
+
healthDegradedThreshold = HEALTH_DEGRADED_THRESHOLD,
|
|
201
|
+
healthRestartThreshold = HEALTH_RESTART_THRESHOLD,
|
|
202
|
+
logFilePath = null, logMaxBytes = CLOUDFLARED_LOG_MAX_BYTES }) {
|
|
145
203
|
this.port = port;
|
|
146
204
|
this.home = home || join(homedir(), '.dsh-bridge');
|
|
147
205
|
this.token = token ? String(token).trim() : null;
|
|
@@ -170,18 +228,42 @@ export class CloudflaredManager {
|
|
|
170
228
|
// 测试注入的 spawn 选项(如 Windows 的 shell);生产为 null 不影响默认行为
|
|
171
229
|
this._spawnOptions = spawnOptions || null;
|
|
172
230
|
|
|
231
|
+
// 运行时健康探针参数(测试可注入小值)
|
|
232
|
+
this.healthProbeIntervalMs = healthProbeIntervalMs;
|
|
233
|
+
this.healthProbeTimeoutMs = healthProbeTimeoutMs;
|
|
234
|
+
this.healthDegradedThreshold = healthDegradedThreshold;
|
|
235
|
+
this.healthRestartThreshold = healthRestartThreshold;
|
|
236
|
+
this.logFilePath = logFilePath || null;
|
|
237
|
+
this.logMaxBytes = logMaxBytes;
|
|
238
|
+
|
|
173
239
|
this.process = null;
|
|
174
240
|
this.url = null;
|
|
175
241
|
this.binaryPath = null;
|
|
176
242
|
this._stopped = false;
|
|
177
243
|
this._retryTimer = null;
|
|
178
244
|
this._restartCount = 0; // 连续启动失败/意外退出次数,就绪后清零
|
|
245
|
+
|
|
246
|
+
this._metricsAddress = null; // 本次 spawn 的 cloudflared metrics 地址(探活入口)
|
|
247
|
+
this._metricsParseTail = ''; // metrics 日志行的跨 chunk 拼接缓冲
|
|
248
|
+
this._probeTimer = null;
|
|
249
|
+
this._probeFailures = 0;
|
|
250
|
+
this._probeInFlight = false; // 防重叠探活堆积
|
|
251
|
+
this._probeCount = 0; // 已完成的探活次数(供测试断言探针确实在跑)
|
|
252
|
+
this._probeLogFailures = 0; // 探针日志通道自身抛错的次数(供自查是否丢过日志)
|
|
253
|
+
this._spawnSeq = 0; // spawn 代数:探活结果据此作废,避免算到新进程头上
|
|
254
|
+
this._healthRecovering = false; // 健康探针触发的重建链路中(此期间不允许终态)
|
|
255
|
+
this._readyState = null; // 最近一次 ready 状态,供探活恢复后回写
|
|
256
|
+
this._logStream = null;
|
|
257
|
+
this._logBytesWritten = 0;
|
|
258
|
+
this._redactCarry = ''; // 跨 chunk 的脱敏残留(防 Token 被切分而漏网)
|
|
179
259
|
}
|
|
180
260
|
|
|
181
261
|
// 异步启动,立即返回——调用方不需要 await
|
|
182
262
|
start() {
|
|
183
263
|
this._stopped = false;
|
|
184
264
|
this._restartCount = 0;
|
|
265
|
+
this._healthRecovering = false;
|
|
266
|
+
this._probeInFlight = false;
|
|
185
267
|
if (this._retryTimer) {
|
|
186
268
|
clearTimeout(this._retryTimer);
|
|
187
269
|
this._retryTimer = null;
|
|
@@ -206,6 +288,9 @@ export class CloudflaredManager {
|
|
|
206
288
|
}
|
|
207
289
|
|
|
208
290
|
// 退避自愈调度:唯一入口在"启动失败"与"就绪后意外退出"。stop()/超限 终止。
|
|
291
|
+
// _healthRecovering 期间(健康探针判定假死后触发的整条重建链路,含重启后握手失败)
|
|
292
|
+
// 永不进入终态——必须覆盖整条链路而不只是那次 kill:重启时网络往往仍不通,
|
|
293
|
+
// 新进程握手失败仍会走这里,若在此处转 error 就把"可自愈的断网"变成人工故障。
|
|
209
294
|
_scheduleRestart(reason) {
|
|
210
295
|
if (this._stopped) return; // 用户已停止,绝不复活
|
|
211
296
|
if (this._retryTimer) return; // 已在倒计时中,避免叠加调度
|
|
@@ -215,15 +300,25 @@ export class CloudflaredManager {
|
|
|
215
300
|
}
|
|
216
301
|
this._restartCount++;
|
|
217
302
|
if (this._restartCount > this.retry.maxRetries) {
|
|
218
|
-
this.
|
|
219
|
-
|
|
303
|
+
if (!this._healthRecovering) {
|
|
304
|
+
this._setState('error', `${reason}(已自动重试 ${this.retry.maxRetries} 次仍失败,请检查网络/Token,或点击「关闭」停止)`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// 健康探针触发的重建链路永不进入终态:长时断网时 cloudflared 自身也是无限退避
|
|
308
|
+
// 重连,这里保持同样语义。退避仍随 _restartCount 增长并封顶 maxDelayMs,
|
|
309
|
+
// 不会退化成高频重启风暴;重新就绪(tryResolve)后该标记自动清除。
|
|
220
310
|
}
|
|
221
311
|
const delay = Math.min(
|
|
222
312
|
this.retry.baseDelayMs * 2 ** (this._restartCount - 1),
|
|
223
313
|
this.retry.maxDelayMs
|
|
224
314
|
);
|
|
225
|
-
|
|
226
|
-
|
|
315
|
+
// 文案必须如实:健康重建链路会故意突破 maxRetries 持续重试,
|
|
316
|
+
// 若沿用"第 N/M 次"会出现"第 9/2 次"这种自相矛盾的提示,反而误导排障。
|
|
317
|
+
const delayText = delay >= 1000 ? `${Math.round(delay / 1000)}s` : `${delay}ms`;
|
|
318
|
+
const beyondCap = this._restartCount > this.retry.maxRetries;
|
|
319
|
+
this._setState('reconnecting', beyondCap
|
|
320
|
+
? `${reason},${delayText} 后继续自动重连(已重试 ${this._restartCount} 次;隧道长时间无法建立,请检查网络与 Token)`
|
|
321
|
+
: `${reason},${delayText} 后自动重连(第 ${this._restartCount}/${this.retry.maxRetries} 次)`);
|
|
227
322
|
this._retryTimer = setTimeout(() => {
|
|
228
323
|
this._retryTimer = null;
|
|
229
324
|
this._restartAttempt();
|
|
@@ -402,6 +497,14 @@ export class CloudflaredManager {
|
|
|
402
497
|
});
|
|
403
498
|
this.process = proc;
|
|
404
499
|
|
|
500
|
+
// 本次 spawn 独立状态:metrics 地址与日志流都随进程生命周期重建;
|
|
501
|
+
// 代数(_spawnSeq)用于让飞行中的旧探活结果作废——重启后 cloudflared 很
|
|
502
|
+
// 可能复用同一个 metrics 端口,仅比对地址字符串不足以识别"这是新进程"。
|
|
503
|
+
this._metricsAddress = null;
|
|
504
|
+
this._metricsParseTail = '';
|
|
505
|
+
this._spawnSeq++;
|
|
506
|
+
this._openLogStream();
|
|
507
|
+
|
|
405
508
|
let resolved = false;
|
|
406
509
|
let timeoutTimer = null;
|
|
407
510
|
// 累积 stderr 尾部(供 exit 时判断确定性失败:CLI 用法错误 / 配置错误)
|
|
@@ -418,8 +521,10 @@ export class CloudflaredManager {
|
|
|
418
521
|
clearTimeout(timeoutTimer);
|
|
419
522
|
timeoutTimer = null;
|
|
420
523
|
}
|
|
421
|
-
//
|
|
524
|
+
// 就绪即证明链路可用:连续失败计数清零,并退出"健康重建"模式
|
|
422
525
|
this._restartCount = 0;
|
|
526
|
+
this._healthRecovering = false;
|
|
527
|
+
this._startHealthProbe(); // 就绪后才探活:启动阶段的问题由握手超时/exit 负责
|
|
423
528
|
resolve();
|
|
424
529
|
}
|
|
425
530
|
};
|
|
@@ -460,10 +565,34 @@ export class CloudflaredManager {
|
|
|
460
565
|
}
|
|
461
566
|
};
|
|
462
567
|
|
|
463
|
-
|
|
568
|
+
// metrics 地址必须先于就绪文本被记录:cloudflared 是先起 metrics 服务再注册连接。
|
|
569
|
+
// 两处稳健性处理:
|
|
570
|
+
// 1) 该日志行本身也可能被 chunk 切断 → 用滚动缓冲拼接后再匹配;
|
|
571
|
+
// 2) 地址可能"迟到"(就绪时还没解析到)→ 一旦补上就补启探活,否则安全网永久不启用。
|
|
572
|
+
const captureMetricsAddress = (text) => {
|
|
573
|
+
if (this._metricsAddress) return;
|
|
574
|
+
const buffered = (this._metricsParseTail + text).slice(-512);
|
|
575
|
+
this._metricsParseTail = buffered;
|
|
576
|
+
const addr = parseMetricsAddress(buffered);
|
|
577
|
+
if (!addr) return;
|
|
578
|
+
this._metricsAddress = addr;
|
|
579
|
+
if (resolved && !this._stopped && !this._probeTimer) {
|
|
580
|
+
this._probeLog('info', 'metrics 地址在就绪后才解析到,补启运行时健康探活: %s', addr);
|
|
581
|
+
this._startHealthProbe();
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
proc.stdout.on('data', (d) => {
|
|
586
|
+
const text = d.toString();
|
|
587
|
+
this._appendCloudflaredLog(text);
|
|
588
|
+
captureMetricsAddress(text);
|
|
589
|
+
parseUrl(text);
|
|
590
|
+
});
|
|
464
591
|
proc.stderr.on('data', (d) => {
|
|
465
592
|
const text = d.toString();
|
|
466
593
|
this.logger?.debug('cloudflared: %s', text.trim());
|
|
594
|
+
this._appendCloudflaredLog(text);
|
|
595
|
+
captureMetricsAddress(text);
|
|
467
596
|
stderrTail = (stderrTail + text).slice(-2000); // 只保留尾部 2KB
|
|
468
597
|
parseUrl(text);
|
|
469
598
|
if (text.includes('Registered tunnel') && !resolved) {
|
|
@@ -476,6 +605,9 @@ export class CloudflaredManager {
|
|
|
476
605
|
clearTimeout(timeoutTimer);
|
|
477
606
|
timeoutTimer = null;
|
|
478
607
|
}
|
|
608
|
+
// 探活定时器与日志流都绑定本次 spawn,退出即回收(自愈会重新建立)
|
|
609
|
+
this._stopHealthProbe();
|
|
610
|
+
this._closeLogStream();
|
|
479
611
|
// exit 时"仍是当前进程"才允许清理引用 + 调度自愈。
|
|
480
612
|
// 自愈已 spawn 新进程后,旧进程迟到的 exit 不满足 stillCurrent → 静默,
|
|
481
613
|
// 避免"新进程连接中、旧 exit 又触发一次重启"的竞态(否则会叠出第三进程)。
|
|
@@ -491,7 +623,8 @@ export class CloudflaredManager {
|
|
|
491
623
|
if (isFatalCloudflaredError(stderrTail)) err.fatal = true;
|
|
492
624
|
reject(err);
|
|
493
625
|
} else if (!this._stopped && stillCurrent) {
|
|
494
|
-
// 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate
|
|
626
|
+
// 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换 / 健康探针判定假死)
|
|
627
|
+
// → 退避自愈。是否允许终态由 _scheduleRestart 依 _healthRecovering 判定。
|
|
495
628
|
this._scheduleRestart(`cloudflared 进程意外退出 (code=${code ?? ''}${signal ? `, ${signal}` : ''})`);
|
|
496
629
|
} else {
|
|
497
630
|
this._setState('idle', '');
|
|
@@ -503,6 +636,8 @@ export class CloudflaredManager {
|
|
|
503
636
|
clearTimeout(timeoutTimer);
|
|
504
637
|
timeoutTimer = null;
|
|
505
638
|
}
|
|
639
|
+
this._stopHealthProbe();
|
|
640
|
+
this._closeLogStream();
|
|
506
641
|
if (!resolved) reject(err);
|
|
507
642
|
});
|
|
508
643
|
|
|
@@ -518,7 +653,298 @@ export class CloudflaredManager {
|
|
|
518
653
|
});
|
|
519
654
|
}
|
|
520
655
|
|
|
521
|
-
|
|
656
|
+
// ── cloudflared 输出落盘(含 Token 脱敏与大小轮转)──────────────────────
|
|
657
|
+
|
|
658
|
+
// Token 属敏感凭据,落盘前一律替换;不依赖上游是否会自行脱敏。
|
|
659
|
+
//
|
|
660
|
+
// 关键:stderr 的 'data' 事件不保证按行对齐,Token 可能被切分在两个块之间。
|
|
661
|
+
// 这里用**逐字符扫描**而不是"整块 split 替换":
|
|
662
|
+
// - 任意位置命中完整 Token → 替换为 ***(完整 Token 绝不会明文落盘);
|
|
663
|
+
// - 只在"剩余不足一个 Token 长度、且恰好是 Token 前缀"时才扣住,等下一块补齐。
|
|
664
|
+
// 不能退化成"扣掉最长的 Token 前缀后缀"这种简化写法:若 Token 自身存在 border
|
|
665
|
+
// (例如首尾字符相同),完整 Token 结尾处也会被误判成前缀而扣下一截,结果前半段
|
|
666
|
+
// 与扣下的那截先后写出、在文件里重新拼成完整 Token(已实测的泄漏路径)。
|
|
667
|
+
_redactToken(text) {
|
|
668
|
+
if (!this.token) return text;
|
|
669
|
+
const token = this.token;
|
|
670
|
+
const L = token.length;
|
|
671
|
+
const combined = this._redactCarry + text;
|
|
672
|
+
this._redactCarry = '';
|
|
673
|
+
let out = '';
|
|
674
|
+
let i = 0;
|
|
675
|
+
while (i < combined.length) {
|
|
676
|
+
if (i + L <= combined.length && combined.startsWith(token, i)) {
|
|
677
|
+
out += '***';
|
|
678
|
+
i += L;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (i + L > combined.length) {
|
|
682
|
+
const rest = combined.slice(i);
|
|
683
|
+
if (token.startsWith(rest)) {
|
|
684
|
+
this._redactCarry = rest; // 可能是被切开的 Token 前半段
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
out += combined[i];
|
|
689
|
+
i++;
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
_openLogStream() {
|
|
695
|
+
this._closeLogStream(); // 幂等:自愈重启时先关掉上一个进程的流,避免句柄堆积
|
|
696
|
+
if (!this.logFilePath) return;
|
|
697
|
+
try {
|
|
698
|
+
mkdirSync(dirname(this.logFilePath), { recursive: true });
|
|
699
|
+
// 同步把文件建出来:createWriteStream 是异步 open,若不在此时落地,
|
|
700
|
+
// 紧随其后的轮转判定会遇到"文件还没存在"而 rename 落空(运行中轮转失效)。
|
|
701
|
+
writeFileSync(this.logFilePath, '', { flag: 'a' });
|
|
702
|
+
this._rotateIfOversize();
|
|
703
|
+
this._logBytesWritten = existsSync(this.logFilePath) ? statSync(this.logFilePath).size : 0;
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// 目录创建/建文件/取大小失败:不阻断日志写入,但必须让问题可见
|
|
706
|
+
this.logger?.warn('cloudflared 日志目录或文件准备失败(本次可能不落盘): %s', err.message);
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const stream = createWriteStream(this.logFilePath, { flags: 'a' });
|
|
710
|
+
stream.on('error', (err) => {
|
|
711
|
+
// 流已不可写:置空引用,避免后续继续往死流写并重复刷同一条告警
|
|
712
|
+
this._logStream = null;
|
|
713
|
+
this.logger?.warn('cloudflared 日志写入失败: %s', err.message);
|
|
714
|
+
});
|
|
715
|
+
this._logStream = stream;
|
|
716
|
+
} catch (err) {
|
|
717
|
+
this._logStream = null;
|
|
718
|
+
this.logger?.warn('无法打开 cloudflared 日志文件 %s: %s', this.logFilePath, err.message);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_rotateIfOversize() {
|
|
723
|
+
if (!this.logFilePath) return;
|
|
724
|
+
try {
|
|
725
|
+
if (existsSync(this.logFilePath) && statSync(this.logFilePath).size >= this.logMaxBytes) {
|
|
726
|
+
renameSync(this.logFilePath, `${this.logFilePath}.1`);
|
|
727
|
+
this._logBytesWritten = 0;
|
|
728
|
+
}
|
|
729
|
+
} catch (err) {
|
|
730
|
+
this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
_closeLogStream() {
|
|
735
|
+
// 无论流是否可用,carry 都必须清空——否则残留的 Token 片段会污染下一个
|
|
736
|
+
// 进程的第一行日志(流先报 error 置空、随后 close 早退的场景实测可复现)。
|
|
737
|
+
const carry = this._redactCarry;
|
|
738
|
+
this._redactCarry = '';
|
|
739
|
+
if (!this._logStream) return;
|
|
740
|
+
// 流关闭时仍扣着的必然是 Token 的一个前缀(见 _redactToken):以占位符收尾。
|
|
741
|
+
// 取舍说明:这会牺牲最多 token.length-1 个正常字符的日志保真度,
|
|
742
|
+
// 但换来"任何 Token 片段都不落盘"。极端情况下宁可少一行尾巴,不可留密钥片段。
|
|
743
|
+
if (carry) this._logStream.write('***');
|
|
744
|
+
this._logStream.end();
|
|
745
|
+
this._logStream = null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
_appendCloudflaredLog(text) {
|
|
749
|
+
if (!this._logStream || !text) return;
|
|
750
|
+
const out = this._redactToken(text);
|
|
751
|
+
if (!out) return;
|
|
752
|
+
const bytes = Buffer.byteLength(out, 'utf8');
|
|
753
|
+
// 运行中也检查轮转:只在 spawn 时检查会让长寿命进程把日志写爆。
|
|
754
|
+
// 这里按"本次运行已写入字节数"判定,不依赖 statSync——写流是异步的,
|
|
755
|
+
// 刚写完就 stat 可能仍是旧大小,会造成轮转判定抖动。
|
|
756
|
+
if (this._logBytesWritten + bytes >= this.logMaxBytes) {
|
|
757
|
+
this._closeLogStream();
|
|
758
|
+
this._renameToRotated();
|
|
759
|
+
this._openLogStream();
|
|
760
|
+
if (!this._logStream) return;
|
|
761
|
+
}
|
|
762
|
+
this._logStream.write(out);
|
|
763
|
+
this._logBytesWritten += bytes;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
_renameToRotated() {
|
|
767
|
+
if (!this.logFilePath) return;
|
|
768
|
+
try {
|
|
769
|
+
if (existsSync(this.logFilePath)) renameSync(this.logFilePath, `${this.logFilePath}.1`);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
|
|
772
|
+
}
|
|
773
|
+
this._logBytesWritten = 0;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── 运行时健康探针 ──────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
// 就绪后启动探活。metrics 地址解析失败时显式告警并跳过——绝不静默降级,
|
|
779
|
+
// 也绝不在没有可信探活通道的情况下误杀进程;同时把降级写进面板可见的状态详情,
|
|
780
|
+
// 否则"整个安全网失效"只会是一行没人看的 journal warning(本次事故的教训)。
|
|
781
|
+
// 探针专用安全日志:探针跑在定时器与子进程数据回调里,异常没有任何上层接住,
|
|
782
|
+
// 一旦 logger 自身抛错就会变成未处理 rejection —— Node 默认 unhandled-rejections=throw
|
|
783
|
+
// 会直接结束宿主进程,即"本该保护服务的探针把服务干掉"。故此处绝不允许抛出。
|
|
784
|
+
_probeLog(level, msg, ...args) {
|
|
785
|
+
try {
|
|
786
|
+
this.logger?.[level]?.(msg, ...args);
|
|
787
|
+
} catch (logErr) {
|
|
788
|
+
// 日志通道本身故障:此处再抛就会拖垮宿主进程,故刻意吞掉。
|
|
789
|
+
// 这不是"静默降级"——要输出的降级信息本身就在这条已损坏的通道上,无处可写;
|
|
790
|
+
// 且关键降级仍经 _setState → 面板独立可见,不依赖 logger。
|
|
791
|
+
// 注意:该计数目前只在单测中被读取,生产尚无出口(后续可接入面板/统计),
|
|
792
|
+
// 因此不要把它当作"生产可自查"的手段。
|
|
793
|
+
this._probeLogFailures++;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
_startHealthProbe() {
|
|
798
|
+
this._stopHealthProbe();
|
|
799
|
+
if (!this._metricsAddress) {
|
|
800
|
+
const detail = `${this._readyState?.detail || '隧道已建立'};⚠️ 运行时健康探活未启用`
|
|
801
|
+
+ '(未识别到 cloudflared metrics 地址),连接器若静默假死将无法自动发现';
|
|
802
|
+
this._probeLog('warn', '未从 cloudflared 输出识别到 metrics 地址,本次运行跳过健康探活'
|
|
803
|
+
+ '(连接器若静默假死将无法自动发现,请检查 cloudflared 版本是否变更了 metrics 日志文案)');
|
|
804
|
+
// record:false —— 这是降级提示,不能污染"纯净的 ready 详情",
|
|
805
|
+
// 否则地址迟到后补启探活时将无法把面板文案恢复成正常状态。
|
|
806
|
+
this._setState('ready', detail, { record: false });
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
// 地址迟到后补启探活:把面板文案恢复为正常的 ready 详情
|
|
810
|
+
if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
|
|
811
|
+
this._probeTimer = setInterval(() => {
|
|
812
|
+
// 必须显式 catch:探针里任何逃逸的异常(如畸形 metrics 地址让 http.get 同步抛错)
|
|
813
|
+
// 都会变成未处理 rejection,而 Node 默认 unhandled-rejections=throw 会直接
|
|
814
|
+
// 结束整个 DSH 主进程 —— 那正是探针本该防止的"服务整体消失"。
|
|
815
|
+
// 用 _probeLog 而非 logger 直调:catch 处理器自身也不允许再抛。
|
|
816
|
+
this._probeOnce().catch((err) => {
|
|
817
|
+
this._probeLog('warn', '健康探活出现未预期异常(已忽略,不影响连接器运行): %s', err?.message ?? err);
|
|
818
|
+
});
|
|
819
|
+
}, this.healthProbeIntervalMs);
|
|
820
|
+
this._probeTimer.unref?.(); // 不因探活定时器而阻止进程退出
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
_stopHealthProbe() {
|
|
824
|
+
if (this._probeTimer) {
|
|
825
|
+
clearInterval(this._probeTimer);
|
|
826
|
+
this._probeTimer = null;
|
|
827
|
+
}
|
|
828
|
+
this._probeFailures = 0;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async _probeOnce() {
|
|
832
|
+
// 防重叠:单次探活最长 healthProbeTimeoutMs,若上一轮还没结论就不再叠加请求
|
|
833
|
+
if (this._probeInFlight) return;
|
|
834
|
+
const addr = this._metricsAddress;
|
|
835
|
+
if (!addr) return;
|
|
836
|
+
const seq = this._spawnSeq; // spawn 代数:据此识别"结果属于哪个进程"
|
|
837
|
+
|
|
838
|
+
this._probeInFlight = true;
|
|
839
|
+
let result;
|
|
840
|
+
try {
|
|
841
|
+
result = await this._checkTunnelReady(addr);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
// 兜底:探针内部任何异常都不得向外逃逸(见 setInterval 处的说明)
|
|
844
|
+
result = { healthy: false, reason: `探针内部异常: ${err?.message ?? err}` };
|
|
845
|
+
} finally {
|
|
846
|
+
this._probeInFlight = false;
|
|
847
|
+
}
|
|
848
|
+
this._probeCount++;
|
|
849
|
+
|
|
850
|
+
// 探活期间可能已被 stop()、或已自愈重启到新进程 → 结果作废。
|
|
851
|
+
// 必须比对代数而非地址:重启后 cloudflared 很可能复用同一个 metrics 端口。
|
|
852
|
+
if (this._stopped || this._spawnSeq !== seq) return;
|
|
853
|
+
|
|
854
|
+
if (result.healthy) {
|
|
855
|
+
if (this._probeFailures >= this.healthDegradedThreshold) {
|
|
856
|
+
this._probeLog('info', '隧道健康探活已自行恢复(此前连续失败 %d 次)', this._probeFailures);
|
|
857
|
+
if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
|
|
858
|
+
}
|
|
859
|
+
this._probeFailures = 0;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
this._probeFailures++;
|
|
864
|
+
const fails = this._probeFailures;
|
|
865
|
+
const why = result.reason || '未知原因';
|
|
866
|
+
|
|
867
|
+
if (fails === this.healthDegradedThreshold) {
|
|
868
|
+
// 第一级:可见降级。此时不杀进程——/ready 为 503 也可能只是 cloudflared
|
|
869
|
+
// 正在按自身退避重连,过早介入反而会把本可自愈的故障变成人工故障。
|
|
870
|
+
this._probeLog('warn', '隧道健康探活连续 %d 次失败(%s):连接器可能已无健康边缘连接,'
|
|
871
|
+
+ '转入降级观察,达 %d 次将强制重建', fails, why, this.healthRestartThreshold);
|
|
872
|
+
this._setState('reconnecting', `健康探活连续 ${fails} 次失败:连接器已无健康边缘连接,`
|
|
873
|
+
+ `正在观察能否自行恢复(${fails}/${this.healthRestartThreshold})`);
|
|
874
|
+
} else if (fails < this.healthDegradedThreshold) {
|
|
875
|
+
this._probeLog('warn', '隧道健康探活失败(第 %d/%d 次): %s;原因: %s',
|
|
876
|
+
fails, this.healthRestartThreshold, addr, why);
|
|
877
|
+
} else {
|
|
878
|
+
this._probeLog('warn', '隧道健康探活仍失败(第 %d/%d 次): %s', fails, this.healthRestartThreshold, why);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (fails < this.healthRestartThreshold) return;
|
|
882
|
+
|
|
883
|
+
// 第二级:持续不恢复 → 判定假死,强制重建。
|
|
884
|
+
// 置 _healthRecovering:整条重建链路(含重启后握手失败)都不允许进入终态。
|
|
885
|
+
this._probeLog('error', '隧道连接器连续 %d 次探活失败(约 %d 分钟):判定为假死,'
|
|
886
|
+
+ '终止进程以触发重建;该重建不设终态上限,网络恢复后仍会自愈',
|
|
887
|
+
fails, Math.round(fails * this.healthProbeIntervalMs / 60000));
|
|
888
|
+
this._stopHealthProbe();
|
|
889
|
+
this._healthRecovering = true;
|
|
890
|
+
this._terminateProcess(); // 不自行重启:交给 exit → _scheduleRestart 既有链路
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// 探活判据:HTTP 200 且 readyConnections > 0(与 cloudflared 官方 /ready 语义一致)。
|
|
894
|
+
// 返回 { healthy, reason }:reason 会进入 warn 级日志,生产关闭 debug 时也能看到原因。
|
|
895
|
+
// 注意:http 的 timeout 选项只是 **socket 不活动** 超时,不是总时限——对"持续滴流
|
|
896
|
+
// 字节"的响应永不触发,因此这里额外加一个绝对截止时间兜底。
|
|
897
|
+
_checkTunnelReady(addr) {
|
|
898
|
+
return new Promise((resolve) => {
|
|
899
|
+
let settled = false;
|
|
900
|
+
let deadline = null;
|
|
901
|
+
let req = null;
|
|
902
|
+
const done = (healthy, reason) => {
|
|
903
|
+
if (settled) return;
|
|
904
|
+
settled = true;
|
|
905
|
+
if (deadline) clearTimeout(deadline);
|
|
906
|
+
try { req?.destroy(); } catch { /* 已结束的请求 destroy 无害 */ }
|
|
907
|
+
resolve({ healthy, reason });
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
deadline = setTimeout(() => {
|
|
911
|
+
done(false, `探活超过总时限 ${this.healthProbeTimeoutMs}ms`);
|
|
912
|
+
}, this.healthProbeTimeoutMs);
|
|
913
|
+
|
|
914
|
+
// httpGet 对畸形 URL 会同步抛错;虽然 parseMetricsAddress 已在入口校验,
|
|
915
|
+
// 这里再兜一层,确保该函数永不向外抛(否则会变成未处理 rejection 拖垮主进程)。
|
|
916
|
+
try {
|
|
917
|
+
req = httpGet(`http://${addr}/ready`, { timeout: this.healthProbeTimeoutMs }, (res) => {
|
|
918
|
+
let body = '';
|
|
919
|
+
res.setEncoding('utf8');
|
|
920
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
921
|
+
res.on('end', () => {
|
|
922
|
+
if (res.statusCode !== 200) {
|
|
923
|
+
done(false, `HTTP ${res.statusCode}`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const parsed = JSON.parse(body);
|
|
928
|
+
const conns = Number(parsed?.readyConnections);
|
|
929
|
+
done(conns > 0, conns > 0 ? '' : `HTTP 200 但 readyConnections=${parsed?.readyConnections}`);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
done(false, `响应无法解析为 JSON: ${err.message}`);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
} catch (err) {
|
|
936
|
+
done(false, `无法发起探活请求(地址可能非法): ${err.message}`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
req.on('timeout', () => { done(false, `socket 不活动超过 ${this.healthProbeTimeoutMs}ms`); });
|
|
940
|
+
req.on('error', (err) => { done(false, `请求失败: ${err.message}`); });
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
_setState(phase, detail, { record = true } = {}) {
|
|
945
|
+
// 记住最近一次"纯净"的 ready 状态:探活由降级恢复、或地址迟到后补启探活时
|
|
946
|
+
// 要回写它,避免面板长期停留在降级文案上(record:false 用于降级提示本身)。
|
|
947
|
+
if (phase === 'ready' && record) this._readyState = { phase, detail };
|
|
522
948
|
this.onStateChange?.({ phase, detail });
|
|
523
949
|
}
|
|
524
950
|
|
|
@@ -529,6 +955,8 @@ export class CloudflaredManager {
|
|
|
529
955
|
this._retryTimer = null;
|
|
530
956
|
}
|
|
531
957
|
this._restartCount = 0;
|
|
958
|
+
this._stopHealthProbe();
|
|
959
|
+
this._closeLogStream();
|
|
532
960
|
if (this.process) {
|
|
533
961
|
this.logger?.info('停止 cloudflared...');
|
|
534
962
|
this._terminateProcess();
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { spawn } from 'node:child_process';
|
|
|
16
16
|
import QRCode from 'qrcode';
|
|
17
17
|
import { installBridgeRpc } from './bridge-rpc.js';
|
|
18
18
|
import { CustomTunnelClient } from './tunnel-client.mjs';
|
|
19
|
-
import { CloudflaredManager } from './cloudflared-manager.mjs';
|
|
19
|
+
import { CloudflaredManager, CLOUDFLARED_LOG_NAME } from './cloudflared-manager.mjs';
|
|
20
20
|
import { PlatformManager } from './platform/manager.js';
|
|
21
21
|
import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
|
|
22
22
|
import { WechatService } from './wechat/index.js';
|
|
@@ -177,7 +177,7 @@ const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 5
|
|
|
177
177
|
<circle cx="256" cy="80" r="16" fill="#4f6ef7"/>
|
|
178
178
|
</svg>`;
|
|
179
179
|
|
|
180
|
-
const
|
|
180
|
+
const HTML_HEAD_ASSETS = `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
181
181
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
182
182
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
183
183
|
<meta name="apple-mobile-web-app-title" content="DSH">
|
|
@@ -187,9 +187,14 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
|
|
|
187
187
|
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
188
188
|
<style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
|
|
189
189
|
<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
`;
|
|
191
|
+
/** 可分别判重注入的三段:各自的 data 标记即去重键(旧版只按 marker 1 判重,
|
|
192
|
+
* 两层桥串联时会出现"上游有 1 没有 2/3 → 整段跳过"的漏注入)。 */
|
|
193
|
+
const HTML_INJECTION_PARTS = [
|
|
194
|
+
{ mark: 'data-dsh-bridge-polyfill="1"', html: HTML_HEAD_ASSETS },
|
|
195
|
+
{ mark: 'data-dsh-bridge-polyfill="2"', html: BROWSER_ABORT_SIGNAL_POLYFILL },
|
|
196
|
+
{ mark: 'data-dsh-bridge-polyfill="3"', html: BROWSER_PROMISE_ITERATOR_POLYFILL },
|
|
197
|
+
];
|
|
193
198
|
|
|
194
199
|
function isCompressed(headers) {
|
|
195
200
|
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
@@ -274,10 +279,11 @@ function sanitizeProxyHeaders(headers, extra) {
|
|
|
274
279
|
*/
|
|
275
280
|
export function parseSystemdUnit(cgroup) {
|
|
276
281
|
const parts = String(cgroup || '').split('/').filter(Boolean);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
// 只认**最内层**组件:若最内层不是 .service(例如 .scope、或 docker/k8s 的容器目录),
|
|
283
|
+
// 说明本进程不是被某个 service 直接托管,绝不能向上游误取祖先单元
|
|
284
|
+
// (否则会把 docker.service / cron.service 之类重启掉)。
|
|
285
|
+
const innermost = parts[parts.length - 1] ?? '';
|
|
286
|
+
return innermost.endsWith('.service') ? innermost : '';
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
/**
|
|
@@ -508,8 +514,10 @@ class ProxyServer {
|
|
|
508
514
|
const outHeaders = { ...proxyRes.headers };
|
|
509
515
|
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
510
516
|
let html = out.toString('utf8');
|
|
511
|
-
|
|
512
|
-
|
|
517
|
+
const missingParts = HTML_INJECTION_PARTS.filter((part) => !html.includes(part.mark));
|
|
518
|
+
if (missingParts.length > 0) {
|
|
519
|
+
const payload = missingParts.map((part) => part.html).join('\n');
|
|
520
|
+
html = html.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
|
|
513
521
|
}
|
|
514
522
|
out = Buffer.from(html, 'utf8');
|
|
515
523
|
} else if (isSessionProjection) {
|
|
@@ -997,6 +1005,9 @@ class BridgeService {
|
|
|
997
1005
|
home: this.home,
|
|
998
1006
|
token: this.cloudflaredConfig?.token,
|
|
999
1007
|
hostname: this.cloudflaredConfig?.hostname,
|
|
1008
|
+
// cloudflared 自身的 stdout/stderr 落盘:事故时 journal 里没有任何连接器日志,
|
|
1009
|
+
// 导致断连时间点与原因完全无法定位(内含 Token 脱敏与 5MB 轮转)。
|
|
1010
|
+
logFilePath: join(this.home ?? join(homedir(), '.dsh-bridge'), CLOUDFLARED_LOG_NAME),
|
|
1000
1011
|
onStateChange: (state) => {
|
|
1001
1012
|
this.cloudflaredState = state;
|
|
1002
1013
|
// 注意:不在 error 时清空 this.cloudflared 引用。
|
|
@@ -1378,26 +1389,39 @@ class BridgeService {
|
|
|
1378
1389
|
|
|
1379
1390
|
if (plan.kind === 'systemd') {
|
|
1380
1391
|
const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
|
|
1381
|
-
|
|
1392
|
+
// 关键:必须以 **systemctl 的退出码** 判定成败,而不是"派生成功"。
|
|
1393
|
+
// `--no-block` 下 systemctl 只发请求就返回(实测往返 ~6ms),但单元名不存在会 rc=5、
|
|
1394
|
+
// 权限不足会 rc=1;旧写法只看 'spawn' 事件 → 必然失败也返回 ok:true 再 exit(0),
|
|
1395
|
+
// 而干净退出不满足 Restart=on-failure → DSH 直接下线且不自动拉起。
|
|
1396
|
+
const outcome = await new Promise((resolve) => {
|
|
1382
1397
|
let settled = false;
|
|
1383
1398
|
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
1384
1399
|
try {
|
|
1385
1400
|
const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
|
|
1386
|
-
child.once?.('error', (err) => settle(err));
|
|
1387
|
-
child.once?.('
|
|
1401
|
+
child.once?.('error', (err) => settle({ error: err }));
|
|
1402
|
+
child.once?.('close', (code) => settle({ code }));
|
|
1388
1403
|
child.unref?.();
|
|
1389
1404
|
} catch (err) {
|
|
1390
|
-
settle(err);
|
|
1405
|
+
settle({ error: err });
|
|
1391
1406
|
}
|
|
1392
1407
|
});
|
|
1393
1408
|
|
|
1394
|
-
if (!
|
|
1395
|
-
writeLog(`已请求 systemd
|
|
1409
|
+
if (!outcome.error && (outcome.code === 0 || outcome.code === null || outcome.code === undefined)) {
|
|
1410
|
+
writeLog(`已请求 systemd 重启(systemctl 退出码 ${outcome.code ?? 'n/a'}):systemctl ${args.join(' ')}`);
|
|
1396
1411
|
scheduleExit(1500);
|
|
1397
1412
|
const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
|
|
1398
1413
|
return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
|
|
1399
1414
|
}
|
|
1400
|
-
|
|
1415
|
+
|
|
1416
|
+
// 失败时**不退化为自派生**:systemd 下主进程干净退出既不会被拉起,
|
|
1417
|
+
// 默认 KillMode=control-group 还会把 detached 助手一起杀掉 —— 那等于"报着重启却永久下线"。
|
|
1418
|
+
// 因此保持现状(DSH 继续运行)并如实报错,由用户手动处理。
|
|
1419
|
+
const reason = outcome.error ? outcome.error.message : `systemctl 退出码 ${outcome.code}`;
|
|
1420
|
+
writeLog(`systemctl 重启失败(${reason}),为不让 DSH 掉线未做任何退出`);
|
|
1421
|
+
return {
|
|
1422
|
+
ok: false,
|
|
1423
|
+
error: `systemctl ${args.join(' ')} 失败:${reason}。DSH 仍在运行;请手动执行「systemctl ${plan.scope === 'user' ? '--user ' : ''}restart ${plan.unit}」或检查单元。`,
|
|
1424
|
+
};
|
|
1401
1425
|
} else if (plan.kind === 'daemon') {
|
|
1402
1426
|
writeLog(`交由守护进程拉起(${plan.reason})`);
|
|
1403
1427
|
scheduleExit(600);
|
package/lib/restart-helper.mjs
CHANGED
|
@@ -81,15 +81,33 @@ async function main() {
|
|
|
81
81
|
|
|
82
82
|
let stdio = 'ignore';
|
|
83
83
|
try { stdio = openSync(childLog, 'a'); } catch { /* 打不开就用 ignore */ }
|
|
84
|
-
|
|
85
|
-
try
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
|
|
85
|
+
// spawn 的失败是异步 'error' 事件(例如 execPath 不存在 → ENOENT),只 try/catch 抓不到;
|
|
86
|
+
// 不挂监听会变成未捕获异常,而且父进程给助手设的是 stdio:'ignore' → 失败细节全部丢失。
|
|
87
|
+
// 这里挂监听并把原因写进日志,且**确认 spawn 成功后才**记录"已派生"。
|
|
88
|
+
const spawned = await new Promise((resolve) => {
|
|
89
|
+
let settled = false;
|
|
90
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(execPath, argv, { cwd, env: process.env, detached: true, stdio: ['ignore', stdio, stdio] });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log(`派生新 dsh 失败(同步异常):${err?.message ?? err}`);
|
|
96
|
+
settle(null);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
child.once('error', (err) => {
|
|
100
|
+
log(`派生新 dsh 失败:${err?.message ?? err}`);
|
|
101
|
+
settle(null);
|
|
102
|
+
});
|
|
103
|
+
child.once('spawn', () => {
|
|
104
|
+
log(`已派生新 dsh:pid=${child.pid ?? '(未知)'}(输出见 ${childLog})`);
|
|
105
|
+
settle(child);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!spawned) process.exit(4);
|
|
110
|
+
spawned.unref();
|
|
93
111
|
|
|
94
112
|
const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
|
|
95
113
|
log(up
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.13",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
5
|
+
"releaseNotes": "【v2.10.13】可靠性加固 + 跟随 DSH 0.1.5 目录选择器\n• 新增:cloudflared 运行时健康探针——每 30s 探活官方 /ready,连续 3 次失败(约 90s)面板可见降级,连续 10 次(约 5min)判定假死自动强制重建,重建永不进终态(网络恢复后自愈)\n• 新增:cloudflared 输出落盘 ~/.dsh/dsh-bridge/cloudflared.log(5MB 轮转 .1),Token 跨 chunk 强制脱敏\n• 修复:连接器假死时公网入口静默失效数天无人发现(HTTP 530 / error code 1033),旧逻辑只监听进程退出故永不触发\n• 修复:本机点「添加工作区」误弹插件的远程抽屉(issue #28)——DSH 0.1.5 自带官方目录选择器且 slots 为 shadow 语义,插件同名 Slot 覆盖了官方实现;现本机 + 官方 picker 可用时不再注册该 Slot(macOS/Windows 走系统原生对话框、无桌面 Linux 走官方网页选择器),旧版 DSH 与远程/移动仍由插件兜底\n• 修复:添加工作区弹窗点过「已注册工作区」后永久卡死(PR #42)——switchToWorkspace 只置位 isSubmitting 从不复位,改为与 doSubmit 对称的 try/finally\n• 修复:探针路径任何异常(畸形地址、日志通道抛错)都不会拖垮 DSH 主进程\n• 测试:新增 18 条探针 + 12 条目录选择器让位 + 3 条弹窗状态复位回归测试\n• 说明:本版需重启一次 dsh(systemctl --user restart dsh-web.service)才生效",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|