@qzsy/dsh-bridge 2.18.0 → 3.0.1
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 +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
|
@@ -1,1277 +0,0 @@
|
|
|
1
|
-
import { spawn, execFileSync } from 'node:child_process';
|
|
2
|
-
import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync, statSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { chmod, stat, unlink, rename } from 'node:fs/promises';
|
|
4
|
-
import { homedir, platform, arch } from 'node:os';
|
|
5
|
-
import { join, dirname } from 'node:path';
|
|
6
|
-
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
import { createHash } from 'node:crypto';
|
|
8
|
-
import { get as httpsGet } from 'node:https';
|
|
9
|
-
import { get as httpGet } from 'node:http';
|
|
10
|
-
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
11
|
-
import { CLOUDFLARED_NETWORK_FIELDS, CLOUDFLARED_NETWORK_FLAGS, buildCloudflaredNetworkArgs, parseFlagSupport } from './cloudflared-network-args.js';
|
|
12
|
-
|
|
13
|
-
// 参数化执行外部命令,替代原先的 execSync(`"${path}" --version`) 字符串拼接
|
|
14
|
-
// (CodeQL js/shell-command-constructed-from-input)。execFileSync 不经过 shell,
|
|
15
|
-
// 参数以数组传递,路径里的引号 / $( ) / 反引号不再具备注入语义。
|
|
16
|
-
//
|
|
17
|
-
// Windows 例外:.cmd / .bat 无法被 execFileSync 直接执行(Node 要求经 shell),
|
|
18
|
-
// 官方 cloudflared 在 Windows 是 .exe 走参数化路径,只有测试注入的假二进制是
|
|
19
|
-
// .cmd,故按扩展名决定是否需要 shell。macOS / Linux 恒为 false。
|
|
20
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
21
|
-
function execBinSync(bin, args, options) {
|
|
22
|
-
const needsShell = IS_WINDOWS && /\.(cmd|bat)$/i.test(bin);
|
|
23
|
-
return execFileSync(bin, args, { ...options, shell: needsShell });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const CLOUDFLARED_VERSION = '2024.10.0';
|
|
27
|
-
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
|
|
28
|
-
const MIN_BINARY_SIZE = 5 * 1024 * 1024; // 最小 5MB,防止下到 HTML 错误页
|
|
29
|
-
const HANDSHAKE_TIMEOUT_MS = 90 * 1000; // 等待隧道就绪的握手超时(默认 90s,可注入)
|
|
30
|
-
const RETRY_BASE_MS = 5 * 1000; // 自愈退避起点 5s
|
|
31
|
-
const RETRY_MAX_MS = 5 * 60 * 1000; // 自愈退避封顶 5min
|
|
32
|
-
const DEFAULT_MAX_RETRIES = 12; // 连续失败超过该次数转为 error,不再无限重试
|
|
33
|
-
|
|
34
|
-
// ── 运行时健康探针 ────────────────────────────────────────────────────────
|
|
35
|
-
// 事故背景:cloudflared 与 Cloudflare 边缘的连接全部掉光后进程仍存活("半死/假死"),
|
|
36
|
-
// 既不退出也不报错,因此只监听 exit 的旧自愈逻辑永远不会触发,隧道会静默失效
|
|
37
|
-
// (现象:公网 530 + 正文 error code: 1033,持续 2 天无人发现)。
|
|
38
|
-
//
|
|
39
|
-
// 关键约束(一手源码 cloudflared 2024.10.0 `tunnelstate/conntracker.go`):/ready 的
|
|
40
|
-
// readyConnections 取自 CountActiveConns(),而 Disconnected / Reconnecting /
|
|
41
|
-
// RegisteringTunnel / Unregistering 全部把 IsConnected 置 false —— 即
|
|
42
|
-
// **正常重连期间 /ready 同样是 503**。因此单次 503 无法区分"连接器假死"与
|
|
43
|
-
// "网络暂时全断、cloudflared 正按自身退避重连"。唯一能区分的是"持续多久",
|
|
44
|
-
// 故采用两级升级,且强制重建永不进入终态(见 _scheduleRestart 的 terminal 选项)。
|
|
45
|
-
const HEALTH_PROBE_INTERVAL_MS = 30 * 1000; // 探活间隔
|
|
46
|
-
const HEALTH_PROBE_TIMEOUT_MS = 5 * 1000; // 单次探活总时限(远程调用防失控)
|
|
47
|
-
const HEALTH_DEGRADED_THRESHOLD = 3; // 连续失败达此数 → 面板可见降级(约 90s 发现)
|
|
48
|
-
const HEALTH_RESTART_THRESHOLD = 10; // 连续失败达此数 → 终止进程强制重建(约 5min)
|
|
49
|
-
|
|
50
|
-
// ── 端到端数据面探针(issue #71 P1-A)─────────────────────────────────────
|
|
51
|
-
// 与 /ready 互补:/ready 只回答"cloudflared 有没有健康边缘连接"(连接器视角),
|
|
52
|
-
// 数据面探针回答"公网用户能否真的访问到入口"——DNS、TLS、CF 边缘、回源整条链路。
|
|
53
|
-
// 事故背景(issue #71):数据面被掐断期间 /ready 全程 200,面板一直显示"正常",
|
|
54
|
-
// 排障被带偏。本探针**只观察不动手**:绝不参与进程生死判定,不与 /ready 的两级
|
|
55
|
-
// 重建逻辑互相干扰;判定见 judgeDataPlaneStatus。
|
|
56
|
-
const DATA_PLANE_PROBE_INTERVAL_MS = 60 * 1000; // 低频探测,不给边缘制造压力
|
|
57
|
-
const DATA_PLANE_PROBE_TIMEOUT_MS = 8 * 1000; // 单次总时限(远程调用防失控)
|
|
58
|
-
const DATA_PLANE_DEGRADED_AFTER = 3; // 连续失败达此数 → 面板降级(约 3 分钟)
|
|
59
|
-
|
|
60
|
-
// ── cloudflared 输出落盘 ──────────────────────────────────────────────────
|
|
61
|
-
// 事故时 journal 里查不到任何 cloudflared 日志,导致事后无法定位断连时间点与原因。
|
|
62
|
-
export const CLOUDFLARED_LOG_NAME = 'cloudflared.log';
|
|
63
|
-
const CLOUDFLARED_LOG_MAX_BYTES = 5 * 1024 * 1024; // 超限轮转为 .1(覆盖旧的)
|
|
64
|
-
|
|
65
|
-
// 从 cloudflared 自报日志中解析 metrics 服务地址(运行时健康探针的唯一入口)。
|
|
66
|
-
// 真实输出形如:INF Starting metrics server on 127.0.0.1:41143/metrics
|
|
67
|
-
// 解析不到 → 显式告警并跳过探活(绝不静默降级,也绝不因探测不到而误杀进程)。
|
|
68
|
-
//
|
|
69
|
-
// 必须在这里就把非法地址拦掉:node:http 的 get() 对畸形 URL 会**同步抛错**
|
|
70
|
-
// (ERR_INVALID_URL),异常会穿透探针 Promise 造成未处理 rejection ——
|
|
71
|
-
// Node 默认 unhandled-rejections=throw 会直接结束整个 DSH 主进程,
|
|
72
|
-
// 也就是说"本该保护服务的探针"反而会把服务干掉。端口必须落在 1..65535。
|
|
73
|
-
export function parseMetricsAddress(text) {
|
|
74
|
-
if (!text) return null;
|
|
75
|
-
const m = /Starting metrics server on (\S+?)\/metrics/.exec(text);
|
|
76
|
-
if (!m) return null;
|
|
77
|
-
const addr = m[1];
|
|
78
|
-
const parts = /^([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})$/.exec(addr);
|
|
79
|
-
if (!parts) return null;
|
|
80
|
-
const port = Number(parts[2]);
|
|
81
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
82
|
-
return addr;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ── 确定性失败特征:cloudflared 因配置/用法错误退出(非网络瞬态)─────────
|
|
86
|
-
// 这类失败重试无意义,识别后直接置 error 让用户看到明确原因,而不是
|
|
87
|
-
// 误判成"意外退出"退避重连 N 次(issue #35 作者建议)。
|
|
88
|
-
function isFatalCloudflaredError(stderrTail) {
|
|
89
|
-
if (!stderrTail) return false;
|
|
90
|
-
return /Incorrect Usage|flag provided but not defined|invalid.*token|unauthorized/i.test(stderrTail);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// 上游 release 不提供任何官方校验和文件(已核实 2024.10.0 资产清单),
|
|
94
|
-
// 因此无法做下载校验和比对;退而求其次:记录产物 SHA-256 指纹供事后审计比对。
|
|
95
|
-
async function sha256File(filePath) {
|
|
96
|
-
return new Promise((resolve, reject) => {
|
|
97
|
-
const hash = createHash('sha256');
|
|
98
|
-
createReadStream(filePath)
|
|
99
|
-
.on('data', (c) => hash.update(c))
|
|
100
|
-
.on('end', () => resolve(hash.digest('hex')))
|
|
101
|
-
.on('error', reject);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 解析 cloudflared --version 输出("cloudflared version 2024.10.0 (built ...)")为版本号
|
|
106
|
-
export function parseCloudflaredVersion(output) {
|
|
107
|
-
const m = /version\s+(\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(output || '');
|
|
108
|
-
return m ? m[1] : null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function getCloudflaredInfo() {
|
|
112
|
-
const os = platform();
|
|
113
|
-
const cpuArch = arch();
|
|
114
|
-
|
|
115
|
-
const platformMap = {
|
|
116
|
-
'win32-x64': { file: 'cloudflared-windows-amd64.exe', name: 'cloudflared.exe' },
|
|
117
|
-
'win32-arm64': { file: 'cloudflared-windows-arm64.exe', name: 'cloudflared.exe' },
|
|
118
|
-
'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', name: 'cloudflared' },
|
|
119
|
-
'darwin-arm64':{ file: 'cloudflared-darwin-arm64.tgz', name: 'cloudflared' },
|
|
120
|
-
'linux-x64': { file: 'cloudflared-linux-amd64', name: 'cloudflared' },
|
|
121
|
-
'linux-arm64': { file: 'cloudflared-linux-arm64', name: 'cloudflared' },
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const key = `${os}-${cpuArch}`;
|
|
125
|
-
const info = platformMap[key];
|
|
126
|
-
if (!info) throw new Error(`不支持的平台: ${os}-${cpuArch}`);
|
|
127
|
-
|
|
128
|
-
const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${info.file}`;
|
|
129
|
-
return { url, name: info.name };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function downloadFile(url, dest, onProgress) {
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
const timer = setTimeout(() => reject(new Error('下载超时(5分钟)')), DOWNLOAD_TIMEOUT);
|
|
135
|
-
|
|
136
|
-
function doGet(targetUrl, redirects = 0) {
|
|
137
|
-
if (redirects > 5) {
|
|
138
|
-
clearTimeout(timer);
|
|
139
|
-
return reject(new Error('重定向次数过多'));
|
|
140
|
-
}
|
|
141
|
-
httpsGet(targetUrl, (res) => {
|
|
142
|
-
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
143
|
-
res.resume();
|
|
144
|
-
return doGet(res.headers.location, redirects + 1);
|
|
145
|
-
}
|
|
146
|
-
if (res.statusCode !== 200) {
|
|
147
|
-
res.resume();
|
|
148
|
-
clearTimeout(timer);
|
|
149
|
-
return reject(new Error(`下载失败: HTTP ${res.statusCode}`));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const total = parseInt(res.headers['content-length'] ?? '0', 10);
|
|
153
|
-
let downloaded = 0;
|
|
154
|
-
res.on('data', (chunk) => {
|
|
155
|
-
downloaded += chunk.length;
|
|
156
|
-
if (onProgress && total > 0) {
|
|
157
|
-
onProgress(Math.round(downloaded / total * 100), downloaded, total);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const fileStream = createWriteStream(dest);
|
|
162
|
-
pipeline(res, fileStream)
|
|
163
|
-
.then(() => { clearTimeout(timer); resolve(); })
|
|
164
|
-
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
165
|
-
}).on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
doGet(url);
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 返回 { path, version }:存在性仍以"exec --version 成功"为准(语义不变),
|
|
173
|
-
// 顺带捕获**实际运行的版本号**(issue #71 P2-B:系统级二进制不校验版本,
|
|
174
|
-
// 面板必须如实展示"到底跑的是哪个、什么版本",排障时不再靠猜)。
|
|
175
|
-
function findSystemCloudflared() {
|
|
176
|
-
const isWin = platform() === 'win32';
|
|
177
|
-
const candidates = [];
|
|
178
|
-
if (isWin) {
|
|
179
|
-
candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
|
|
180
|
-
} else {
|
|
181
|
-
candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
for (const bin of candidates) {
|
|
185
|
-
try {
|
|
186
|
-
if (bin.includes('/') || bin.includes('\\')) {
|
|
187
|
-
if (!existsSync(bin)) continue;
|
|
188
|
-
}
|
|
189
|
-
const out = execBinSync(bin, ['--version'], { encoding: 'utf8', timeout: 3000 });
|
|
190
|
-
return { path: bin, version: parseCloudflaredVersion(String(out ?? '')) };
|
|
191
|
-
} catch {}
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* 数据面响应判定(纯函数,便于单测)。
|
|
198
|
-
* 任意 <500 的 HTTP 响应都证明"公网入口 → 边缘 → 回源"链路活着
|
|
199
|
-
* (401/403/302 都算通——鉴权门禁本身就是数据面可达的证据);
|
|
200
|
-
* >=500 多为 CF 回源错误(502/521-524/530):边缘可达但源站不通。
|
|
201
|
-
* @param {number} statusCode
|
|
202
|
-
* @returns {{ ok: boolean, reason: string }}
|
|
203
|
-
*/
|
|
204
|
-
export function judgeDataPlaneStatus(statusCode) {
|
|
205
|
-
if (statusCode >= 500) return { ok: false, reason: `HTTP ${statusCode}(公网入口可达,但回源失败)` };
|
|
206
|
-
return { ok: true, reason: `HTTP ${statusCode}` };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* 失败原因标注(纯函数,便于单测;issue #71 第 6 条的可观测性)。
|
|
211
|
-
* 证书/握手类错误最常见的成因就是 DNS 被污染后落到伪造端点(issue 实测:污染 IP
|
|
212
|
-
* 出示自签名证书)——把这类错误显式分类,并附上实际解析到的 IP,面板一眼可辨。
|
|
213
|
-
* @param {string} reason 原始错误
|
|
214
|
-
* @param {string|null} [ip] 失败时补查到的解析 IP
|
|
215
|
-
* @returns {string} 标注后的可读原因
|
|
216
|
-
*/
|
|
217
|
-
export function classifyDataPlaneFailure(reason, ip = null) {
|
|
218
|
-
const base = String(reason ?? '未知错误');
|
|
219
|
-
const certLike = /certificate|altname|self[- ]?signed|does not match|unable to verify|handshake/i.test(base);
|
|
220
|
-
let out = base;
|
|
221
|
-
if (ip && !base.includes(ip)) out += `(解析到 ${ip})`;
|
|
222
|
-
if (certLike) out = `疑似 DNS 污染或伪造证书:${out}`;
|
|
223
|
-
return out;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* 把面板配置的 hostname(可带/可不带协议)规范为完整入口 URL;空值返回 null。
|
|
228
|
-
* 与固定隧道就绪时构造 this.url 的规则保持同源(命名一致性),数据面探针复用。
|
|
229
|
-
* @param {string|null|undefined} hostnameOrUrl
|
|
230
|
-
* @returns {string|null}
|
|
231
|
-
*/
|
|
232
|
-
export function normalizePublicUrl(hostnameOrUrl) {
|
|
233
|
-
const v = String(hostnameOrUrl ?? '').trim();
|
|
234
|
-
if (!v) return null;
|
|
235
|
-
return v.startsWith('http') ? v : `https://${v}`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export class CloudflaredManager {
|
|
239
|
-
/**
|
|
240
|
-
* @param {object} opts
|
|
241
|
-
* @param {string} [opts.binaryPath] 直接指定可执行文件路径(测试注入假 cloudflared 用;
|
|
242
|
-
* 生产不传,走 findSystemCloudflared → ~/.dsh-bridge/bin 的自动准备逻辑)。
|
|
243
|
-
* @param {number} [opts.retryPolicy] 自愈重试策略:
|
|
244
|
-
* - false / null:禁用自动重启(保持旧版"意外退出置 error"行为)
|
|
245
|
-
* - 对象 { baseDelayMs, maxDelayMs, maxRetries }:覆盖默认退避参数(默认 5s→5min,12 次封顶)
|
|
246
|
-
* @param {boolean} [opts.noAutoupdate=true] 是否禁用 cloudflared 自身的 autoupdate 自替换。
|
|
247
|
-
* autoupdate spawn 出的新进程不受本 manager 监督,会破坏"钉死版本 + 可自愈"的语义,默认必须关闭。
|
|
248
|
-
* @param {string} [opts.binaryVersion=CLOUDFLARED_VERSION] 本 manager 期望管理的 cloudflared 版本。
|
|
249
|
-
* 仅对自管理二进制(~/.dsh-bridge/bin)强制校验;系统级全局二进制尊重用户选择、不强制。
|
|
250
|
-
* @param {number} [opts.handshakeTimeoutMs=90000] 等待隧道就绪的握手超时(测试可注入小值)。
|
|
251
|
-
* @param {Function} [opts.spawnImpl] 可注入的 spawn 实现(测试用;生产不传 = 默认 child_process.spawn)。
|
|
252
|
-
* 刻意只暴露"实现"而非"spawn 选项":否则调用方可传 shell:true 把参数交给 shell 解释。
|
|
253
|
-
* @param {number} [opts.healthProbeIntervalMs=30000] 就绪后运行时探活间隔。
|
|
254
|
-
* @param {number} [opts.healthProbeTimeoutMs=5000] 单次探活总时限。
|
|
255
|
-
* @param {number} [opts.healthDegradedThreshold=3] 连续失败达此数即在面板上显示降级
|
|
256
|
-
* (默认约 90s——这是"发现故障"的时点)。
|
|
257
|
-
* @param {number} [opts.healthRestartThreshold=10] 连续失败达此数才终止进程强制重建
|
|
258
|
-
* (默认约 5min)。必须显著大于 degraded 阈值:/ready 无法区分"假死"与
|
|
259
|
-
* "正在自行重连",只有持续时长能区分,过早重建会误杀本可自愈的连接器。
|
|
260
|
-
* @param {string|null} [opts.logFilePath=null] cloudflared stdout/stderr 落盘路径;
|
|
261
|
-
* null 表示不落盘(默认)。生产由调用方显式传入(见 lib/index.js),
|
|
262
|
-
* 避免库的默认行为在单测里往真实运维日志目录写测试内容。
|
|
263
|
-
* @param {number} [opts.logMaxBytes=5242880] 单文件超限后轮转为 `<logFilePath>.1`。
|
|
264
|
-
*/
|
|
265
|
-
constructor({ port, home, token, hostname, onStateChange, logger,
|
|
266
|
-
binaryPath, retryPolicy, noAutoupdate = true, binaryVersion = CLOUDFLARED_VERSION,
|
|
267
|
-
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnImpl = null,
|
|
268
|
-
healthProbeIntervalMs = HEALTH_PROBE_INTERVAL_MS,
|
|
269
|
-
healthProbeTimeoutMs = HEALTH_PROBE_TIMEOUT_MS,
|
|
270
|
-
healthDegradedThreshold = HEALTH_DEGRADED_THRESHOLD,
|
|
271
|
-
healthRestartThreshold = HEALTH_RESTART_THRESHOLD,
|
|
272
|
-
dataPlaneProbeIntervalMs = DATA_PLANE_PROBE_INTERVAL_MS,
|
|
273
|
-
dataPlaneProbeTimeoutMs = DATA_PLANE_PROBE_TIMEOUT_MS,
|
|
274
|
-
dataPlaneDegradedAfter = DATA_PLANE_DEGRADED_AFTER,
|
|
275
|
-
network = null,
|
|
276
|
-
networkProbeExec = null,
|
|
277
|
-
logFilePath = null, logMaxBytes = CLOUDFLARED_LOG_MAX_BYTES }) {
|
|
278
|
-
this.port = port;
|
|
279
|
-
this.home = home || join(homedir(), '.dsh-bridge');
|
|
280
|
-
this.token = token ? String(token).trim() : null;
|
|
281
|
-
this.hostname = hostname ? String(hostname).trim() : null;
|
|
282
|
-
this.onStateChange = onStateChange;
|
|
283
|
-
this.logger = logger;
|
|
284
|
-
|
|
285
|
-
// 显式注入的可执行文件(测试用):跳过自动查找/下载,直接按此路径运行
|
|
286
|
-
this._injectedBinaryPath = binaryPath || null;
|
|
287
|
-
|
|
288
|
-
// 自愈重试策略:默认开启;false/null 显式禁用(旧版行为)
|
|
289
|
-
if (retryPolicy === false || retryPolicy === null) {
|
|
290
|
-
this.retry = null;
|
|
291
|
-
} else {
|
|
292
|
-
const p = retryPolicy && typeof retryPolicy === 'object' ? retryPolicy : {};
|
|
293
|
-
this.retry = {
|
|
294
|
-
baseDelayMs: p.baseDelayMs ?? RETRY_BASE_MS,
|
|
295
|
-
maxDelayMs: p.maxDelayMs ?? RETRY_MAX_MS,
|
|
296
|
-
maxRetries: p.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
this.noAutoupdate = noAutoupdate !== false;
|
|
301
|
-
this.binaryVersion = binaryVersion || CLOUDFLARED_VERSION;
|
|
302
|
-
this.handshakeTimeoutMs = handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
303
|
-
// 只允许注入"spawn 实现"(生产为 null = 用默认 spawn);**不提供外部 spawn 选项**——
|
|
304
|
-
// 否则调用方可传 shell:true,把参数交给 shell 解释(CodeQL js/shell-command-constructed-from-input 的成因)。
|
|
305
|
-
this._spawn = spawnImpl || spawn;
|
|
306
|
-
|
|
307
|
-
// 运行时健康探针参数(测试可注入小值)
|
|
308
|
-
this.healthProbeIntervalMs = healthProbeIntervalMs;
|
|
309
|
-
this.healthProbeTimeoutMs = healthProbeTimeoutMs;
|
|
310
|
-
this.healthDegradedThreshold = healthDegradedThreshold;
|
|
311
|
-
this.healthRestartThreshold = healthRestartThreshold;
|
|
312
|
-
this.logFilePath = logFilePath || null;
|
|
313
|
-
this.logMaxBytes = logMaxBytes;
|
|
314
|
-
|
|
315
|
-
// 网络参数(issue #71 P1-B):{ protocol, edgeIpVersion, region },空串 = 不传参(零变化)
|
|
316
|
-
this.networkOptions = Object.fromEntries(
|
|
317
|
-
CLOUDFLARED_NETWORK_FIELDS.map((f) => [f, typeof network?.[f] === 'string' ? network[f].trim() : '']),
|
|
318
|
-
);
|
|
319
|
-
this._networkFlagSupport = null;
|
|
320
|
-
// 测试注入:替换能力探测的执行器(生产为 null → 用 execBinSync 跑真实二进制)
|
|
321
|
-
this._networkProbeExec = networkProbeExec || null;
|
|
322
|
-
|
|
323
|
-
// 数据面探针参数(issue #71 P1-A;测试可注入小值)
|
|
324
|
-
this.dataPlaneProbeIntervalMs = dataPlaneProbeIntervalMs;
|
|
325
|
-
this.dataPlaneProbeTimeoutMs = dataPlaneProbeTimeoutMs;
|
|
326
|
-
this.dataPlaneDegradedAfter = dataPlaneDegradedAfter;
|
|
327
|
-
this._dataPlaneTimer = null;
|
|
328
|
-
this._dataPlaneInFlight = false;
|
|
329
|
-
// 探测世代:stop 后递增,作废「停止瞬间仍在飞行」的探测结果,
|
|
330
|
-
// 避免它晚到后改写 checkedAt(历史用例在本机高负载下会偶发失败)。
|
|
331
|
-
this._dataPlaneEpoch = 0;
|
|
332
|
-
this._dataPlane = {
|
|
333
|
-
state: 'idle', url: null, checkedAt: null, resolvedIp: null,
|
|
334
|
-
consecutiveFailures: 0, lastError: null,
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
this.process = null;
|
|
338
|
-
this.url = null;
|
|
339
|
-
this.binaryPath = null;
|
|
340
|
-
// 实际使用的二进制画像(issue #71 P2-B):source ∈ injected|system|managed|null(未启动)
|
|
341
|
-
this.binarySource = null;
|
|
342
|
-
this.binaryVersionActual = null;
|
|
343
|
-
this._stopped = false;
|
|
344
|
-
this._retryTimer = null;
|
|
345
|
-
this._restartCount = 0; // 连续启动失败/意外退出次数,就绪后清零
|
|
346
|
-
|
|
347
|
-
this._metricsAddress = null; // 本次 spawn 的 cloudflared metrics 地址(探活入口)
|
|
348
|
-
this._metricsParseTail = ''; // metrics 日志行的跨 chunk 拼接缓冲
|
|
349
|
-
this._probeTimer = null;
|
|
350
|
-
this._probeFailures = 0;
|
|
351
|
-
this._probeInFlight = false; // 防重叠探活堆积
|
|
352
|
-
this._probeCount = 0; // 已完成的探活次数(供测试断言探针确实在跑)
|
|
353
|
-
this._probeLogFailures = 0; // 探针日志通道自身抛错的次数(供自查是否丢过日志)
|
|
354
|
-
this._spawnSeq = 0; // spawn 代数:探活结果据此作废,避免算到新进程头上
|
|
355
|
-
this._healthRecovering = false; // 健康探针触发的重建链路中(此期间不允许终态)
|
|
356
|
-
this._readyState = null; // 最近一次 ready 状态,供探活恢复后回写
|
|
357
|
-
this._logStream = null;
|
|
358
|
-
this._logBytesWritten = 0;
|
|
359
|
-
this._redactCarry = ''; // 跨 chunk 的脱敏残留(防 Token 被切分而漏网)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// 异步启动,立即返回——调用方不需要 await
|
|
363
|
-
start() {
|
|
364
|
-
this._stopped = false;
|
|
365
|
-
this._restartCount = 0;
|
|
366
|
-
this._healthRecovering = false;
|
|
367
|
-
this._probeInFlight = false;
|
|
368
|
-
if (this._retryTimer) {
|
|
369
|
-
clearTimeout(this._retryTimer);
|
|
370
|
-
this._retryTimer = null;
|
|
371
|
-
}
|
|
372
|
-
this._setState('connecting', '正在初始化...');
|
|
373
|
-
// 数据面探针随 manager 生命周期启动(而非随进程 ready):进程断开期间公网
|
|
374
|
-
// 入口本就应当被探测为不可用,这正是面板需要如实看到的状态(只观察不动手)。
|
|
375
|
-
this._startDataPlaneProbe();
|
|
376
|
-
this._run().catch((err) => {
|
|
377
|
-
this.logger?.error('cloudflared 启动失败: %s', err.message);
|
|
378
|
-
// 确定性失败(用法错误/配置错误,如 Incorrect Usage)重试无意义 → 直接 error,
|
|
379
|
-
// 让用户看到明确原因;瞬态失败(网络/握手/秒退)才退避自愈
|
|
380
|
-
if (err && err.fatal) {
|
|
381
|
-
this._setState('error', err.message);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async _run() {
|
|
389
|
-
await this._ensureBinary();
|
|
390
|
-
if (this._stopped) return;
|
|
391
|
-
// 能力探测只在配置了网络参数时执行(默认零开销);结果在 manager 生命周期内缓存,
|
|
392
|
-
// 自愈重启不重复探测(二进制路径未变)。
|
|
393
|
-
await this._detectNetworkFlagSupport();
|
|
394
|
-
if (this._stopped) return;
|
|
395
|
-
await this._startProcess();
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// 退避自愈调度:唯一入口在"启动失败"与"就绪后意外退出"。stop()/超限 终止。
|
|
399
|
-
// _healthRecovering 期间(健康探针判定假死后触发的整条重建链路,含重启后握手失败)
|
|
400
|
-
// 永不进入终态——必须覆盖整条链路而不只是那次 kill:重启时网络往往仍不通,
|
|
401
|
-
// 新进程握手失败仍会走这里,若在此处转 error 就把"可自愈的断网"变成人工故障。
|
|
402
|
-
_scheduleRestart(reason) {
|
|
403
|
-
if (this._stopped) return; // 用户已停止,绝不复活
|
|
404
|
-
if (this._retryTimer) return; // 已在倒计时中,避免叠加调度
|
|
405
|
-
if (!this.retry) {
|
|
406
|
-
this._setState('error', reason);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
this._restartCount++;
|
|
410
|
-
if (this._restartCount > this.retry.maxRetries) {
|
|
411
|
-
if (!this._healthRecovering) {
|
|
412
|
-
this._setState('error', `${reason}(已自动重试 ${this.retry.maxRetries} 次仍失败,请检查网络/Token,或点击「关闭」停止)`);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
// 健康探针触发的重建链路永不进入终态:长时断网时 cloudflared 自身也是无限退避
|
|
416
|
-
// 重连,这里保持同样语义。退避仍随 _restartCount 增长并封顶 maxDelayMs,
|
|
417
|
-
// 不会退化成高频重启风暴;重新就绪(tryResolve)后该标记自动清除。
|
|
418
|
-
}
|
|
419
|
-
const delay = Math.min(
|
|
420
|
-
this.retry.baseDelayMs * 2 ** (this._restartCount - 1),
|
|
421
|
-
this.retry.maxDelayMs
|
|
422
|
-
);
|
|
423
|
-
// 文案必须如实:健康重建链路会故意突破 maxRetries 持续重试,
|
|
424
|
-
// 若沿用"第 N/M 次"会出现"第 9/2 次"这种自相矛盾的提示,反而误导排障。
|
|
425
|
-
const delayText = delay >= 1000 ? `${Math.round(delay / 1000)}s` : `${delay}ms`;
|
|
426
|
-
const beyondCap = this._restartCount > this.retry.maxRetries;
|
|
427
|
-
this._setState('reconnecting', beyondCap
|
|
428
|
-
? `${reason},${delayText} 后继续自动重连(已重试 ${this._restartCount} 次;隧道长时间无法建立,请检查网络与 Token)`
|
|
429
|
-
: `${reason},${delayText} 后自动重连(第 ${this._restartCount}/${this.retry.maxRetries} 次)`);
|
|
430
|
-
this._retryTimer = setTimeout(() => {
|
|
431
|
-
this._retryTimer = null;
|
|
432
|
-
this._restartAttempt();
|
|
433
|
-
}, delay);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// 一次实际的自愈尝试:成功(ready)后 _restartCount 清零,失败/退出由 exit/超时/异常再次调度
|
|
437
|
-
_restartAttempt() {
|
|
438
|
-
if (this._stopped) return;
|
|
439
|
-
this._setState('connecting', '正在自动重连...');
|
|
440
|
-
this._run().catch((err) => {
|
|
441
|
-
this.logger?.error('cloudflared 自动重连失败: %s', err.message);
|
|
442
|
-
// 自愈期间遇到确定性失败同样终止重试(如用户改坏配置后重启)
|
|
443
|
-
if (err && err.fatal) {
|
|
444
|
-
this._setState('error', err.message);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
_terminateProcess() {
|
|
452
|
-
const p = this.process;
|
|
453
|
-
if (!p) return;
|
|
454
|
-
try {
|
|
455
|
-
if (platform() === 'win32') {
|
|
456
|
-
// Windows 不支持 SIGTERM,用 taskkill 强制终止
|
|
457
|
-
spawn('taskkill', ['/pid', String(p.pid), '/f', '/t'], { stdio: 'ignore' });
|
|
458
|
-
} else {
|
|
459
|
-
p.kill('SIGTERM');
|
|
460
|
-
}
|
|
461
|
-
} catch {}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// 校验自管理二进制是否匹配期望版本;系统级二进制不校验(尊重用户安装)
|
|
465
|
-
_checkManagedBinaryVersion(binPath) {
|
|
466
|
-
try {
|
|
467
|
-
const out = execBinSync(binPath, ['--version'], { encoding: 'utf8', timeout: 3000 });
|
|
468
|
-
const ver = parseCloudflaredVersion(out);
|
|
469
|
-
if (!ver) return { ok: false, reason: `无法解析版本输出: ${(out || '').trim().slice(0, 80)}` };
|
|
470
|
-
if (ver !== this.binaryVersion) {
|
|
471
|
-
return { ok: false, reason: `版本不匹配: 期望 ${this.binaryVersion},实际 ${ver}` };
|
|
472
|
-
}
|
|
473
|
-
return { ok: true, version: ver };
|
|
474
|
-
} catch (err) {
|
|
475
|
-
return { ok: false, reason: err.message };
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
async _ensureBinary() {
|
|
480
|
-
// 0. 测试注入的可执行文件:直接使用,跳过查找/下载/版本校验
|
|
481
|
-
if (this._injectedBinaryPath) {
|
|
482
|
-
this.binaryPath = this._injectedBinaryPath;
|
|
483
|
-
// 注入的是测试替身/外部指定二进制:不对它执行 --version(语义未知),如实留空
|
|
484
|
-
this.binarySource = 'injected';
|
|
485
|
-
this.binaryVersionActual = null;
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制(尊重用户版本,不做强制校验)
|
|
490
|
-
const systemBin = findSystemCloudflared();
|
|
491
|
-
if (systemBin) {
|
|
492
|
-
this.binaryPath = systemBin.path;
|
|
493
|
-
this.binarySource = 'system';
|
|
494
|
-
this.binaryVersionActual = systemBin.version;
|
|
495
|
-
this.logger?.info('优先使用系统全局 cloudflared: %s (version=%s)', systemBin.path, systemBin.version ?? '未知');
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const { url, name } = getCloudflaredInfo();
|
|
500
|
-
const binDir = join(this.home, 'bin');
|
|
501
|
-
const binPath = join(binDir, name);
|
|
502
|
-
this.binaryPath = binPath;
|
|
503
|
-
|
|
504
|
-
// 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
|
|
505
|
-
if (existsSync(binPath)) {
|
|
506
|
-
try {
|
|
507
|
-
const s = await stat(binPath);
|
|
508
|
-
if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
|
|
509
|
-
// 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
|
|
510
|
-
const fd = readFileSync(binPath);
|
|
511
|
-
const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
|
|
512
|
-
if (isGzip) {
|
|
513
|
-
this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
|
|
514
|
-
await unlink(binPath).catch(() => {});
|
|
515
|
-
} else {
|
|
516
|
-
// macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
|
|
517
|
-
if (platform() !== 'win32') {
|
|
518
|
-
await chmod(binPath, 0o755).catch(() => {});
|
|
519
|
-
if (platform() === 'darwin') {
|
|
520
|
-
try { execBinSync('xattr', ['-d', 'com.apple.quarantine', binPath], { stdio: 'ignore' }); } catch {}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
// 版本钉死校验:cloudflared autoupdate 可能已把钉死的版本自替换成新版,
|
|
524
|
-
// 自管理二进制必须与期望版本一致,否则回滚重下——版本控制权留在插件手里。
|
|
525
|
-
const check = this._checkManagedBinaryVersion(binPath);
|
|
526
|
-
if (check.ok) {
|
|
527
|
-
this.logger?.info('cloudflared 已存在且验证通过: %s (version=%s)', binPath, check.version);
|
|
528
|
-
this.binarySource = 'managed';
|
|
529
|
-
this.binaryVersionActual = check.version;
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
this.logger?.warn('现有 cloudflared 二进制验证失败(%s),准备重新下载', check.reason);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
} catch (verifyErr) {
|
|
536
|
-
this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
|
|
537
|
-
}
|
|
538
|
-
// 损坏 / 版本不符 / 无法执行:删掉重下
|
|
539
|
-
await unlink(binPath).catch(() => {});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
this._setState('downloading', '正在下载 cloudflared (~30MB)...');
|
|
543
|
-
this.logger?.info('从 %s 下载 cloudflared', url);
|
|
544
|
-
|
|
545
|
-
mkdirSync(binDir, { recursive: true });
|
|
546
|
-
const tempPath = `${binPath}.tmp`;
|
|
547
|
-
|
|
548
|
-
try {
|
|
549
|
-
await downloadFile(url, tempPath, (percent, downloaded, total) => {
|
|
550
|
-
if (this._stopped) return;
|
|
551
|
-
const mb = (downloaded / 1024 / 1024).toFixed(1);
|
|
552
|
-
const totalMb = (total / 1024 / 1024).toFixed(1);
|
|
553
|
-
this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
|
|
557
|
-
try {
|
|
558
|
-
execBinSync('tar', ['-xzf', tempPath, '-C', binDir]);
|
|
559
|
-
await unlink(tempPath).catch(() => {});
|
|
560
|
-
} catch (tarErr) {
|
|
561
|
-
this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
|
|
562
|
-
throw new Error(`解压 cloudflared 失败: ${tarErr.message}`, { cause: tarErr });
|
|
563
|
-
}
|
|
564
|
-
} else {
|
|
565
|
-
if (existsSync(binPath)) await unlink(binPath).catch(() => {});
|
|
566
|
-
await rename(tempPath, binPath);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
if (platform() !== 'win32') {
|
|
570
|
-
await chmod(binPath, 0o755).catch(() => {});
|
|
571
|
-
if (platform() === 'darwin') {
|
|
572
|
-
try { execBinSync('xattr', ['-d', 'com.apple.quarantine', binPath], { stdio: 'ignore' }); } catch {}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// 下载产物也必须通过版本校验(钉死版本)
|
|
577
|
-
const check = this._checkManagedBinaryVersion(binPath);
|
|
578
|
-
if (!check.ok) {
|
|
579
|
-
throw new Error(`下载的 cloudflared 版本校验失败: ${check.reason}`);
|
|
580
|
-
}
|
|
581
|
-
this.binarySource = 'managed';
|
|
582
|
-
this.binaryVersionActual = check.version;
|
|
583
|
-
this.logger?.info('cloudflared 下载并准备完成 (version=%s, sha256=%s)', check.version, await sha256File(binPath));
|
|
584
|
-
} catch (err) {
|
|
585
|
-
await unlink(tempPath).catch(() => {});
|
|
586
|
-
throw new Error(`准备 cloudflared 失败: ${err.message}`, { cause: err });
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
_startProcess() {
|
|
591
|
-
return new Promise((resolve, reject) => {
|
|
592
|
-
if (this._stopped) return reject(new Error('已取消'));
|
|
593
|
-
|
|
594
|
-
this._setState('connecting', this._restartCount > 0 ? '正在自动重连...' : '正在连接 Cloudflare...');
|
|
595
|
-
|
|
596
|
-
// 网络参数(issue #71 P1-B):白名单 + 能力门控后的追加 flag;未配置即空数组(零变化)
|
|
597
|
-
const { args: networkArgs, skipped } = buildCloudflaredNetworkArgs(this.networkOptions, this._networkFlagSupport);
|
|
598
|
-
for (const s of skipped) this.logger?.warn('cloudflared 网络参数 %s 未生效:%s', s.field, s.reason);
|
|
599
|
-
const args = this.token
|
|
600
|
-
// --no-autoupdate 是 tunnel 子命令的全局 flag,须在 run 之前(cloudflared 2024.10.0,issue #35)
|
|
601
|
-
? ['tunnel', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), ...networkArgs, 'run', '--token', this.token]
|
|
602
|
-
: ['tunnel', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), ...networkArgs, '--url', `http://127.0.0.1:${this.port}`];
|
|
603
|
-
|
|
604
|
-
// 隐藏日志中的 token 敏感字段
|
|
605
|
-
const safeArgs = this.token ? args.map((a) => (a === this.token ? '***' : a)) : args;
|
|
606
|
-
this.logger?.info('启动 cloudflared: %s %s', this.binaryPath, safeArgs.join(' '));
|
|
607
|
-
|
|
608
|
-
// 环境变量双保险禁用 autoupdate(部分打包/脚本以 env 方式读取)
|
|
609
|
-
const spawnEnv = this.noAutoupdate
|
|
610
|
-
? { ...process.env, NO_AUTOUPDATE: 'true' }
|
|
611
|
-
: process.env;
|
|
612
|
-
|
|
613
|
-
// 固定选项:绝不经过 shell(args 原样传给 cloudflared,参数里的元字符不会被解释)
|
|
614
|
-
const proc = this._spawn(this.binaryPath, args, {
|
|
615
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
616
|
-
env: spawnEnv,
|
|
617
|
-
});
|
|
618
|
-
this.process = proc;
|
|
619
|
-
|
|
620
|
-
// 本次 spawn 独立状态:metrics 地址与日志流都随进程生命周期重建;
|
|
621
|
-
// 代数(_spawnSeq)用于让飞行中的旧探活结果作废——重启后 cloudflared 很
|
|
622
|
-
// 可能复用同一个 metrics 端口,仅比对地址字符串不足以识别"这是新进程"。
|
|
623
|
-
this._metricsAddress = null;
|
|
624
|
-
this._metricsParseTail = '';
|
|
625
|
-
this._spawnSeq++;
|
|
626
|
-
this._openLogStream();
|
|
627
|
-
|
|
628
|
-
let resolved = false;
|
|
629
|
-
let timeoutTimer = null;
|
|
630
|
-
// 累积 stderr 尾部(供 exit 时判断确定性失败:CLI 用法错误 / 配置错误)
|
|
631
|
-
let stderrTail = '';
|
|
632
|
-
|
|
633
|
-
// 仅当当前引用的仍是本次 spawn 的进程时才清空——自愈重启后旧进程的
|
|
634
|
-
// exit 事件可能晚于新进程 spawn 触发;exit handler 内用 stillCurrent
|
|
635
|
-
// (this.process === proc)判定,避免误清新进程引用(stop() 将无法终止它)。
|
|
636
|
-
|
|
637
|
-
const tryResolve = () => {
|
|
638
|
-
if (!resolved) {
|
|
639
|
-
resolved = true;
|
|
640
|
-
if (timeoutTimer) {
|
|
641
|
-
clearTimeout(timeoutTimer);
|
|
642
|
-
timeoutTimer = null;
|
|
643
|
-
}
|
|
644
|
-
// 迟到的就绪必须撤回"已排定的重试"。
|
|
645
|
-
//
|
|
646
|
-
// 实测竞态(Windows 上假件 spawn 延迟 ~390–411ms 恰好压着 handshakeTimeoutMs=400ms):
|
|
647
|
-
// 握手超时先触发 → _terminateProcess() + reject → _scheduleRestart() 排定 20ms 后的
|
|
648
|
-
// 重试;紧接着同一进程才吐出 "Registered tunnel connection" → 走到这里。
|
|
649
|
-
// 此时若留着那枚定时器,20ms 后 _restartAttempt() 会把**已经就绪的**连接器再杀一次:
|
|
650
|
-
// 既白白多一轮重启,又把 _restartCount(此处刚清零)重新推向 maxRetries →
|
|
651
|
-
// 偶发进入终态 error,正是本模块最该避免的"可自愈的断网变成人工故障"。
|
|
652
|
-
//
|
|
653
|
-
// 两个前置条件缺一不可:
|
|
654
|
-
// 1) this.process === proc —— 只撤本次 spawn 的重试,绝不误撤新进程那条链;
|
|
655
|
-
// 2) 进程仍存活(无 exitCode/signalCode)—— 若它其实已经退出,那枚定时器就是
|
|
656
|
-
// 这条链**唯一**的重启来源(exit 处理器早已因 _retryTimer 非空而提前返回),
|
|
657
|
-
// 撤回会让隧道永久无人接管。宁可多一次多余重启,也不能留下死隧道。
|
|
658
|
-
const procAlive = proc.exitCode === null && proc.signalCode === null;
|
|
659
|
-
if (this._retryTimer && this.process === proc && procAlive) {
|
|
660
|
-
clearTimeout(this._retryTimer);
|
|
661
|
-
this._retryTimer = null;
|
|
662
|
-
}
|
|
663
|
-
// 就绪即证明链路可用:连续失败计数清零,并退出"健康重建"模式
|
|
664
|
-
this._restartCount = 0;
|
|
665
|
-
this._healthRecovering = false;
|
|
666
|
-
this._startHealthProbe(); // 就绪后才探活:启动阶段的问题由握手超时/exit 负责
|
|
667
|
-
resolve();
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
|
|
671
|
-
// 1. 命名/Token 隧道:通过握手日志判定就绪,使用预设固定域名
|
|
672
|
-
const parseNamedTunnel = (text) => {
|
|
673
|
-
if (!this.token) return;
|
|
674
|
-
if (
|
|
675
|
-
(text.includes('Registered tunnel') ||
|
|
676
|
-
text.includes('registered connIndex') ||
|
|
677
|
-
text.includes('Connection') && text.includes('registered') ||
|
|
678
|
-
text.includes('Updated to new configuration') ||
|
|
679
|
-
text.includes('Route propagated')) &&
|
|
680
|
-
!resolved
|
|
681
|
-
) {
|
|
682
|
-
const fixedUrl = normalizePublicUrl(this.hostname);
|
|
683
|
-
this.url = fixedUrl;
|
|
684
|
-
this._setState('ready', fixedUrl ? `固定隧道已建立 (${fixedUrl})` : '固定隧道已建立');
|
|
685
|
-
this.logger?.info('cloudflared 固定隧道就绪: %s', this.url || 'Token 模式');
|
|
686
|
-
tryResolve();
|
|
687
|
-
}
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
// 2. 免费临时隧道:从 stdout/stderr 解析随机分配的 trycloudflare.com 域名
|
|
691
|
-
const parseUrl = (text) => {
|
|
692
|
-
if (this.token) {
|
|
693
|
-
parseNamedTunnel(text);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
697
|
-
if (match && !resolved) {
|
|
698
|
-
this.url = match[0];
|
|
699
|
-
this._setState('ready', '临时隧道已建立');
|
|
700
|
-
this.logger?.info('cloudflared 临时隧道就绪: %s', this.url);
|
|
701
|
-
tryResolve();
|
|
702
|
-
}
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
// metrics 地址必须先于就绪文本被记录:cloudflared 是先起 metrics 服务再注册连接。
|
|
706
|
-
// 两处稳健性处理:
|
|
707
|
-
// 1) 该日志行本身也可能被 chunk 切断 → 用滚动缓冲拼接后再匹配;
|
|
708
|
-
// 2) 地址可能"迟到"(就绪时还没解析到)→ 一旦补上就补启探活,否则安全网永久不启用。
|
|
709
|
-
const captureMetricsAddress = (text) => {
|
|
710
|
-
if (this._metricsAddress) return;
|
|
711
|
-
const buffered = (this._metricsParseTail + text).slice(-512);
|
|
712
|
-
this._metricsParseTail = buffered;
|
|
713
|
-
const addr = parseMetricsAddress(buffered);
|
|
714
|
-
if (!addr) return;
|
|
715
|
-
this._metricsAddress = addr;
|
|
716
|
-
if (resolved && !this._stopped && !this._probeTimer) {
|
|
717
|
-
this._probeLog('info', 'metrics 地址在就绪后才解析到,补启运行时健康探活: %s', addr);
|
|
718
|
-
this._startHealthProbe();
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
proc.stdout.on('data', (d) => {
|
|
723
|
-
const text = d.toString();
|
|
724
|
-
this._appendCloudflaredLog(text);
|
|
725
|
-
captureMetricsAddress(text);
|
|
726
|
-
parseUrl(text);
|
|
727
|
-
});
|
|
728
|
-
proc.stderr.on('data', (d) => {
|
|
729
|
-
const text = d.toString();
|
|
730
|
-
this.logger?.debug('cloudflared: %s', text.trim());
|
|
731
|
-
this._appendCloudflaredLog(text);
|
|
732
|
-
captureMetricsAddress(text);
|
|
733
|
-
stderrTail = (stderrTail + text).slice(-2000); // 只保留尾部 2KB
|
|
734
|
-
parseUrl(text);
|
|
735
|
-
if (text.includes('Registered tunnel') && !resolved) {
|
|
736
|
-
this._setState('connecting', '隧道已注册,等待就绪...');
|
|
737
|
-
}
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
proc.on('exit', (code, signal) => {
|
|
741
|
-
if (timeoutTimer) {
|
|
742
|
-
clearTimeout(timeoutTimer);
|
|
743
|
-
timeoutTimer = null;
|
|
744
|
-
}
|
|
745
|
-
// 探活定时器与日志流都绑定本次 spawn,退出即回收(自愈会重新建立)
|
|
746
|
-
this._stopHealthProbe();
|
|
747
|
-
this._closeLogStream();
|
|
748
|
-
// exit 时"仍是当前进程"才允许清理引用 + 调度自愈。
|
|
749
|
-
// 自愈已 spawn 新进程后,旧进程迟到的 exit 不满足 stillCurrent → 静默,
|
|
750
|
-
// 避免"新进程连接中、旧 exit 又触发一次重启"的竞态(否则会叠出第三进程)。
|
|
751
|
-
const stillCurrent = this.process === proc;
|
|
752
|
-
if (stillCurrent) this.process = null;
|
|
753
|
-
this.url = null;
|
|
754
|
-
if (!resolved) {
|
|
755
|
-
// 就绪前退出 = 启动失败,交给 reject → start()/restart 的 catch 进入退避自愈。
|
|
756
|
-
// 若 stderr 显示确定性配置/用法错误(Incorrect Usage 等)→ 标 fatal,
|
|
757
|
-
// catch 收到后直接置 error,不再无意义退避重连。
|
|
758
|
-
const msg = `cloudflared 启动失败: ${isFatalCloudflaredError(stderrTail) ? '配置错误(' + (stderrTail.trim().split('\n').pop() || '请检查 Token 与参数') + ')' : `cloudflared 退出,code=${code ?? ''} signal=${signal ?? ''}`}`;
|
|
759
|
-
const err = new Error(msg);
|
|
760
|
-
if (isFatalCloudflaredError(stderrTail)) err.fatal = true;
|
|
761
|
-
reject(err);
|
|
762
|
-
} else if (!this._stopped && stillCurrent) {
|
|
763
|
-
// 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换 / 健康探针判定假死)
|
|
764
|
-
// → 退避自愈。是否允许终态由 _scheduleRestart 依 _healthRecovering 判定。
|
|
765
|
-
this._scheduleRestart(`cloudflared 进程意外退出 (code=${code ?? ''}${signal ? `, ${signal}` : ''})`);
|
|
766
|
-
} else {
|
|
767
|
-
this._setState('idle', '');
|
|
768
|
-
}
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
proc.on('error', (err) => {
|
|
772
|
-
if (timeoutTimer) {
|
|
773
|
-
clearTimeout(timeoutTimer);
|
|
774
|
-
timeoutTimer = null;
|
|
775
|
-
}
|
|
776
|
-
this._stopHealthProbe();
|
|
777
|
-
this._closeLogStream();
|
|
778
|
-
if (!resolved) reject(err);
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
// 握手超时:只终止进程、不置 _stopped——让退出/重试路径接管(kill 后进程
|
|
782
|
-
// exit 会触发 reject;此处兜底 reject 保证进程僵死时也能推进)
|
|
783
|
-
timeoutTimer = setTimeout(() => {
|
|
784
|
-
if (!resolved) {
|
|
785
|
-
this.logger?.warn('等待隧道 URL 超时(%ss),终止进程并进入自动重试', Math.round(this.handshakeTimeoutMs / 1000));
|
|
786
|
-
this._terminateProcess();
|
|
787
|
-
reject(new Error(`等待隧道 URL 超时(${Math.round(this.handshakeTimeoutMs / 1000)}秒)`));
|
|
788
|
-
}
|
|
789
|
-
}, this.handshakeTimeoutMs);
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// ── cloudflared 输出落盘(含 Token 脱敏与大小轮转)──────────────────────
|
|
794
|
-
|
|
795
|
-
// Token 属敏感凭据,落盘前一律替换;不依赖上游是否会自行脱敏。
|
|
796
|
-
//
|
|
797
|
-
// 关键:stderr 的 'data' 事件不保证按行对齐,Token 可能被切分在两个块之间。
|
|
798
|
-
// 这里用**逐字符扫描**而不是"整块 split 替换":
|
|
799
|
-
// - 任意位置命中完整 Token → 替换为 ***(完整 Token 绝不会明文落盘);
|
|
800
|
-
// - 只在"剩余不足一个 Token 长度、且恰好是 Token 前缀"时才扣住,等下一块补齐。
|
|
801
|
-
// 不能退化成"扣掉最长的 Token 前缀后缀"这种简化写法:若 Token 自身存在 border
|
|
802
|
-
// (例如首尾字符相同),完整 Token 结尾处也会被误判成前缀而扣下一截,结果前半段
|
|
803
|
-
// 与扣下的那截先后写出、在文件里重新拼成完整 Token(已实测的泄漏路径)。
|
|
804
|
-
_redactToken(text) {
|
|
805
|
-
if (!this.token) return text;
|
|
806
|
-
const token = this.token;
|
|
807
|
-
const L = token.length;
|
|
808
|
-
const combined = this._redactCarry + text;
|
|
809
|
-
this._redactCarry = '';
|
|
810
|
-
let out = '';
|
|
811
|
-
let i = 0;
|
|
812
|
-
while (i < combined.length) {
|
|
813
|
-
if (i + L <= combined.length && combined.startsWith(token, i)) {
|
|
814
|
-
out += '***';
|
|
815
|
-
i += L;
|
|
816
|
-
continue;
|
|
817
|
-
}
|
|
818
|
-
if (i + L > combined.length) {
|
|
819
|
-
const rest = combined.slice(i);
|
|
820
|
-
if (token.startsWith(rest)) {
|
|
821
|
-
this._redactCarry = rest; // 可能是被切开的 Token 前半段
|
|
822
|
-
break;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
out += combined[i];
|
|
826
|
-
i++;
|
|
827
|
-
}
|
|
828
|
-
return out;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
_openLogStream() {
|
|
832
|
-
this._closeLogStream(); // 幂等:自愈重启时先关掉上一个进程的流,避免句柄堆积
|
|
833
|
-
if (!this.logFilePath) return;
|
|
834
|
-
try {
|
|
835
|
-
mkdirSync(dirname(this.logFilePath), { recursive: true });
|
|
836
|
-
// 同步把文件建出来:createWriteStream 是异步 open,若不在此时落地,
|
|
837
|
-
// 紧随其后的轮转判定会遇到"文件还没存在"而 rename 落空(运行中轮转失效)。
|
|
838
|
-
writeFileSync(this.logFilePath, '', { flag: 'a' });
|
|
839
|
-
this._rotateIfOversize();
|
|
840
|
-
this._logBytesWritten = existsSync(this.logFilePath) ? statSync(this.logFilePath).size : 0;
|
|
841
|
-
} catch (err) {
|
|
842
|
-
// 目录创建/建文件/取大小失败:不阻断日志写入,但必须让问题可见
|
|
843
|
-
this.logger?.warn('cloudflared 日志目录或文件准备失败(本次可能不落盘): %s', err.message);
|
|
844
|
-
}
|
|
845
|
-
try {
|
|
846
|
-
const stream = createWriteStream(this.logFilePath, { flags: 'a' });
|
|
847
|
-
stream.on('error', (err) => {
|
|
848
|
-
// 流已不可写:置空引用,避免后续继续往死流写并重复刷同一条告警
|
|
849
|
-
this._logStream = null;
|
|
850
|
-
this.logger?.warn('cloudflared 日志写入失败: %s', err.message);
|
|
851
|
-
});
|
|
852
|
-
this._logStream = stream;
|
|
853
|
-
} catch (err) {
|
|
854
|
-
this._logStream = null;
|
|
855
|
-
this.logger?.warn('无法打开 cloudflared 日志文件 %s: %s', this.logFilePath, err.message);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
_rotateIfOversize() {
|
|
860
|
-
if (!this.logFilePath) return;
|
|
861
|
-
try {
|
|
862
|
-
if (existsSync(this.logFilePath) && statSync(this.logFilePath).size >= this.logMaxBytes) {
|
|
863
|
-
renameSync(this.logFilePath, `${this.logFilePath}.1`);
|
|
864
|
-
this._logBytesWritten = 0;
|
|
865
|
-
}
|
|
866
|
-
} catch (err) {
|
|
867
|
-
this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
_closeLogStream() {
|
|
872
|
-
// 无论流是否可用,carry 都必须清空——否则残留的 Token 片段会污染下一个
|
|
873
|
-
// 进程的第一行日志(流先报 error 置空、随后 close 早退的场景实测可复现)。
|
|
874
|
-
const carry = this._redactCarry;
|
|
875
|
-
this._redactCarry = '';
|
|
876
|
-
if (!this._logStream) return;
|
|
877
|
-
// 流关闭时仍扣着的必然是 Token 的一个前缀(见 _redactToken):以占位符收尾。
|
|
878
|
-
// 取舍说明:这会牺牲最多 token.length-1 个正常字符的日志保真度,
|
|
879
|
-
// 但换来"任何 Token 片段都不落盘"。极端情况下宁可少一行尾巴,不可留密钥片段。
|
|
880
|
-
if (carry) this._logStream.write('***');
|
|
881
|
-
this._logStream.end();
|
|
882
|
-
this._logStream = null;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
_appendCloudflaredLog(text) {
|
|
886
|
-
if (!this._logStream || !text) return;
|
|
887
|
-
const out = this._redactToken(text);
|
|
888
|
-
if (!out) return;
|
|
889
|
-
const bytes = Buffer.byteLength(out, 'utf8');
|
|
890
|
-
// 运行中也检查轮转:只在 spawn 时检查会让长寿命进程把日志写爆。
|
|
891
|
-
// 这里按"本次运行已写入字节数"判定,不依赖 statSync——写流是异步的,
|
|
892
|
-
// 刚写完就 stat 可能仍是旧大小,会造成轮转判定抖动。
|
|
893
|
-
if (this._logBytesWritten + bytes >= this.logMaxBytes) {
|
|
894
|
-
this._closeLogStream();
|
|
895
|
-
this._renameToRotated();
|
|
896
|
-
this._openLogStream();
|
|
897
|
-
if (!this._logStream) return;
|
|
898
|
-
}
|
|
899
|
-
this._logStream.write(out);
|
|
900
|
-
this._logBytesWritten += bytes;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
_renameToRotated() {
|
|
904
|
-
if (!this.logFilePath) return;
|
|
905
|
-
try {
|
|
906
|
-
if (existsSync(this.logFilePath)) renameSync(this.logFilePath, `${this.logFilePath}.1`);
|
|
907
|
-
} catch (err) {
|
|
908
|
-
this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
|
|
909
|
-
}
|
|
910
|
-
this._logBytesWritten = 0;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// ── 运行时健康探针 ──────────────────────────────────────────────────────
|
|
914
|
-
|
|
915
|
-
// 就绪后启动探活。metrics 地址解析失败时显式告警并跳过——绝不静默降级,
|
|
916
|
-
// 也绝不在没有可信探活通道的情况下误杀进程;同时把降级写进面板可见的状态详情,
|
|
917
|
-
// 否则"整个安全网失效"只会是一行没人看的 journal warning(本次事故的教训)。
|
|
918
|
-
// 探针专用安全日志:探针跑在定时器与子进程数据回调里,异常没有任何上层接住,
|
|
919
|
-
// 一旦 logger 自身抛错就会变成未处理 rejection —— Node 默认 unhandled-rejections=throw
|
|
920
|
-
// 会直接结束宿主进程,即"本该保护服务的探针把服务干掉"。故此处绝不允许抛出。
|
|
921
|
-
_probeLog(level, msg, ...args) {
|
|
922
|
-
try {
|
|
923
|
-
this.logger?.[level]?.(msg, ...args);
|
|
924
|
-
} catch (logErr) {
|
|
925
|
-
// 日志通道本身故障:此处再抛就会拖垮宿主进程,故刻意吞掉。
|
|
926
|
-
// 这不是"静默降级"——要输出的降级信息本身就在这条已损坏的通道上,无处可写;
|
|
927
|
-
// 且关键降级仍经 _setState → 面板独立可见,不依赖 logger。
|
|
928
|
-
// 注意:该计数目前只在单测中被读取,生产尚无出口(后续可接入面板/统计),
|
|
929
|
-
// 因此不要把它当作"生产可自查"的手段。
|
|
930
|
-
this._probeLogFailures++;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
_startHealthProbe() {
|
|
935
|
-
this._stopHealthProbe();
|
|
936
|
-
if (!this._metricsAddress) {
|
|
937
|
-
const detail = `${this._readyState?.detail || '隧道已建立'};⚠️ 运行时健康探活未启用`
|
|
938
|
-
+ '(未识别到 cloudflared metrics 地址),连接器若静默假死将无法自动发现';
|
|
939
|
-
this._probeLog('warn', '未从 cloudflared 输出识别到 metrics 地址,本次运行跳过健康探活'
|
|
940
|
-
+ '(连接器若静默假死将无法自动发现,请检查 cloudflared 版本是否变更了 metrics 日志文案)');
|
|
941
|
-
// record:false —— 这是降级提示,不能污染"纯净的 ready 详情",
|
|
942
|
-
// 否则地址迟到后补启探活时将无法把面板文案恢复成正常状态。
|
|
943
|
-
this._setState('ready', detail, { record: false });
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
// 地址迟到后补启探活:把面板文案恢复为正常的 ready 详情
|
|
947
|
-
if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
|
|
948
|
-
this._probeTimer = setInterval(() => {
|
|
949
|
-
// 必须显式 catch:探针里任何逃逸的异常(如畸形 metrics 地址让 http.get 同步抛错)
|
|
950
|
-
// 都会变成未处理 rejection,而 Node 默认 unhandled-rejections=throw 会直接
|
|
951
|
-
// 结束整个 DSH 主进程 —— 那正是探针本该防止的"服务整体消失"。
|
|
952
|
-
// 用 _probeLog 而非 logger 直调:catch 处理器自身也不允许再抛。
|
|
953
|
-
this._probeOnce().catch((err) => {
|
|
954
|
-
this._probeLog('warn', '健康探活出现未预期异常(已忽略,不影响连接器运行): %s', err?.message ?? err);
|
|
955
|
-
});
|
|
956
|
-
}, this.healthProbeIntervalMs);
|
|
957
|
-
this._probeTimer.unref?.(); // 不因探活定时器而阻止进程退出
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
_stopHealthProbe() {
|
|
961
|
-
if (this._probeTimer) {
|
|
962
|
-
clearInterval(this._probeTimer);
|
|
963
|
-
this._probeTimer = null;
|
|
964
|
-
}
|
|
965
|
-
this._probeFailures = 0;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// ── 面板可观测性:二进制画像 + 数据面探针(issue #71 P2-B / P1-A)────────
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* 实际使用的二进制信息(issue #71 P2-B):路径 / 实际版本 / 来源。
|
|
972
|
-
* 系统级二进制刻意不强制版本(尊重用户安装,见 _ensureBinary),因此"实际版本"
|
|
973
|
-
* 必须如实展示,而不是沿用钉死的 CLOUDFLARED_VERSION 让排障被误导。
|
|
974
|
-
* @returns {{ path: string|null, version: string|null, source: 'injected'|'system'|'managed'|null }}
|
|
975
|
-
*/
|
|
976
|
-
getBinaryInfo() {
|
|
977
|
-
return { path: this.binaryPath, version: this.binaryVersionActual, source: this.binarySource };
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/**
|
|
981
|
-
* 能力探测(issue #71 P1-B):实际二进制认不认识这几个网络 flag。
|
|
982
|
-
* 用行为法而非 --help 文本法——本机实测 2024.10.0 的 --protocol/--ha-connections
|
|
983
|
-
* 在 --help 里根本不出现但可用,help 文本不可信。判据:
|
|
984
|
-
* "flag provided but not defined" → 不支持;其余输出(接受/枚举校验/token 校验)
|
|
985
|
-
* 都证明 flag 存在。探测用 dummy token 触发本地校验即退(不产生任何网络连接)。
|
|
986
|
-
* 仅在配置了网络参数时执行;结果缓存,自愈重启不重复探测。
|
|
987
|
-
*/
|
|
988
|
-
async _detectNetworkFlagSupport() {
|
|
989
|
-
if (this._networkFlagSupport) return;
|
|
990
|
-
const configured = CLOUDFLARED_NETWORK_FIELDS.some((f) => this.networkOptions[f] !== '');
|
|
991
|
-
if (!configured) return;
|
|
992
|
-
const support = {};
|
|
993
|
-
const exec = this._networkProbeExec
|
|
994
|
-
? (bin, args) => this._networkProbeExec(bin, args)
|
|
995
|
-
: (bin, args) => execBinSync(bin, args, { encoding: 'utf8', timeout: 5000 });
|
|
996
|
-
for (const [field, spec] of Object.entries(CLOUDFLARED_NETWORK_FLAGS)) {
|
|
997
|
-
let out;
|
|
998
|
-
try {
|
|
999
|
-
out = exec(this.binaryPath, ['tunnel', spec.flag, '__dsh_probe_invalid__', 'run', '--token', 'dummy']) ?? '';
|
|
1000
|
-
} catch (err) {
|
|
1001
|
-
// 只认二进制自己的输出(stdout/stderr);err.message(如 ENOENT spawn 失败)不算——
|
|
1002
|
-
// 它必然不含 "not defined",混进来会把"根本没跑起来"误判成"支持该 flag"。
|
|
1003
|
-
// 输出为空 → parseFlagSupport 判 false → 保守跳过(fail-open 到安全侧)。
|
|
1004
|
-
out = [err?.stdout, err?.stderr].filter(Boolean).join('\n');
|
|
1005
|
-
}
|
|
1006
|
-
support[field] = parseFlagSupport(out);
|
|
1007
|
-
if (!support[field]) {
|
|
1008
|
-
this.logger?.warn('当前 cloudflared 不支持 %s(%s),对应网络参数将被跳过', spec.flag, this.binaryPath);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
this._networkFlagSupport = support;
|
|
1012
|
-
this.logger?.info('cloudflared 网络参数能力探测: protocol=%s edge-ip-version=%s region=%s', support.protocol, support.edgeIpVersion, support.region);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
/** 面板保存新域名后同步给探针(不重启隧道,下一次探测即按新入口) */
|
|
1016
|
-
setHostname(hostname) {
|
|
1017
|
-
this.hostname = hostname ? String(hostname).trim() : null;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/** 当前应探测的公网入口:优先固定域名(进程断开时仍可探测),回退就绪时解析出的 URL */
|
|
1021
|
-
_dataPlaneTarget() {
|
|
1022
|
-
return normalizePublicUrl(this.hostname) || this.url || null;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
/**
|
|
1026
|
-
* 单次数据面探测(公开:单测直接驱动;生产由 start() 拉起的低频循环调用)。
|
|
1027
|
-
* 状态机:未配置/未就绪 → idle;成功 → ok(清零);连续失败 1..N-1 次 → unstable;
|
|
1028
|
-
* >=N 次 → degraded。进入 degraded 只告警一次,恢复时再记一条 info。
|
|
1029
|
-
* 防重叠与 _probeOnce 同款:飞行中重复调用返回 null。
|
|
1030
|
-
* @returns {Promise<object|null>} 结果快照
|
|
1031
|
-
*/
|
|
1032
|
-
async probeDataPlaneOnce() {
|
|
1033
|
-
if (this._dataPlaneInFlight) return null;
|
|
1034
|
-
const epochAtStart = this._dataPlaneEpoch;
|
|
1035
|
-
const target = this._dataPlaneTarget();
|
|
1036
|
-
if (!target) {
|
|
1037
|
-
this._dataPlane = { ...this._dataPlane, state: 'idle', checkedAt: Date.now(), lastError: '隧道未运行或尚未就绪' };
|
|
1038
|
-
return this._dataPlane;
|
|
1039
|
-
}
|
|
1040
|
-
let origin;
|
|
1041
|
-
try {
|
|
1042
|
-
origin = new URL(target).origin;
|
|
1043
|
-
} catch {
|
|
1044
|
-
this._dataPlane = { ...this._dataPlane, state: 'degraded', checkedAt: Date.now(), lastError: '公网地址格式非法: ' + target };
|
|
1045
|
-
return this._dataPlane;
|
|
1046
|
-
}
|
|
1047
|
-
const probeUrl = origin + '/'; // 只探 origin:路径与查询不参与可达性判定
|
|
1048
|
-
this._dataPlaneInFlight = true;
|
|
1049
|
-
let result;
|
|
1050
|
-
try {
|
|
1051
|
-
result = await new Promise((resolve) => {
|
|
1052
|
-
const get = probeUrl.startsWith('https:') ? httpsGet : httpGet;
|
|
1053
|
-
let settled = false;
|
|
1054
|
-
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
1055
|
-
let req;
|
|
1056
|
-
try {
|
|
1057
|
-
// 保持默认严格 TLS 校验:证书异常(例如 DNS 污染落点的自签名证书)正是
|
|
1058
|
-
// 数据面探针要如实上报的故障,绝不能为了"探得通"而放松验证。
|
|
1059
|
-
req = get(probeUrl, { timeout: this.dataPlaneProbeTimeoutMs }, (res) => {
|
|
1060
|
-
const verdict = judgeDataPlaneStatus(res.statusCode ?? 0);
|
|
1061
|
-
res.resume(); // 响应体不关心,丢弃即可
|
|
1062
|
-
done({ ok: verdict.ok, reason: verdict.reason, ip: res.socket?.remoteAddress ?? null });
|
|
1063
|
-
});
|
|
1064
|
-
req.on('timeout', () => { req.destroy(new Error('数据面探测超时 ' + this.dataPlaneProbeTimeoutMs + 'ms')); });
|
|
1065
|
-
req.on('error', (err) => done({ ok: false, reason: err?.message ?? String(err), ip: null }));
|
|
1066
|
-
} catch (err) {
|
|
1067
|
-
done({ ok: false, reason: err?.message ?? String(err), ip: null });
|
|
1068
|
-
}
|
|
1069
|
-
});
|
|
1070
|
-
} finally {
|
|
1071
|
-
this._dataPlaneInFlight = false;
|
|
1072
|
-
}
|
|
1073
|
-
if (this._stopped) return null; // 探测飞行中被 stop():结果作废,不再改写状态
|
|
1074
|
-
// 探测循环已被 _stopDataPlaneProbe() 停止(或重启换了一代):飞行中的结果同样作废
|
|
1075
|
-
if (this._dataPlaneEpoch !== epochAtStart) return null;
|
|
1076
|
-
const now = Date.now();
|
|
1077
|
-
if (result.ok) {
|
|
1078
|
-
const wasDown = this._dataPlane.state === 'unstable' || this._dataPlane.state === 'degraded';
|
|
1079
|
-
this._dataPlane = {
|
|
1080
|
-
state: 'ok', url: probeUrl, checkedAt: now, resolvedIp: result.ip,
|
|
1081
|
-
consecutiveFailures: 0, lastError: null,
|
|
1082
|
-
};
|
|
1083
|
-
if (wasDown) this._probeLog('info', '数据面探测已恢复:公网入口可达(%s,IP %s)', probeUrl, result.ip ?? '-');
|
|
1084
|
-
else this._probeLog('debug', '数据面探测正常(%s,IP %s)', probeUrl, result.ip ?? '-');
|
|
1085
|
-
} else {
|
|
1086
|
-
// 失败时补查 DNS 解析 IP 并分类证书类错误(issue #71 第 6 条:污染落点的
|
|
1087
|
-
// 自签名证书要能在面板上一眼看出来,而不是一句含糊的 socket error)
|
|
1088
|
-
if (!result.ip) {
|
|
1089
|
-
try { result.ip = (await dnsLookup(new URL(target).hostname)).address; } catch { /* DNS 本身失败则留空 */ }
|
|
1090
|
-
}
|
|
1091
|
-
result.reason = classifyDataPlaneFailure(result.reason, result.ip);
|
|
1092
|
-
const failures = (this._dataPlane.consecutiveFailures || 0) + 1;
|
|
1093
|
-
const prevState = this._dataPlane.state;
|
|
1094
|
-
const nextState = failures >= this.dataPlaneDegradedAfter ? 'degraded' : 'unstable';
|
|
1095
|
-
this._dataPlane = {
|
|
1096
|
-
state: nextState, url: probeUrl, checkedAt: now, resolvedIp: null,
|
|
1097
|
-
consecutiveFailures: failures, lastError: result.reason,
|
|
1098
|
-
};
|
|
1099
|
-
if (nextState === 'degraded' && prevState !== 'degraded') {
|
|
1100
|
-
// 只在进入降级时告警一次:长时间断网时每分钟刷一条毫无价值
|
|
1101
|
-
this._probeLog('warn', '数据面连续 %d 次探测失败(%s):公网入口当前不可用(注意 /ready 可能仍显示正常——这正是本探针存在的意义)', failures, result.reason);
|
|
1102
|
-
} else {
|
|
1103
|
-
this._probeLog('debug', '数据面探测失败(第 %d/%d 次): %s', failures, this.dataPlaneDegradedAfter, result.reason);
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
return this._dataPlane;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
_startDataPlaneProbe() {
|
|
1110
|
-
if (this._dataPlaneTimer) return; // 幂等:start() 与自愈重启路径可能重入
|
|
1111
|
-
this._dataPlaneEpoch += 1; // 新一代探测:旧世代飞行中的结果自动作废
|
|
1112
|
-
const epoch = this._dataPlaneEpoch;
|
|
1113
|
-
const tick = () => {
|
|
1114
|
-
if (this._dataPlaneEpoch !== epoch) return;
|
|
1115
|
-
// 显式 catch:探针异常绝不能变成 unhandled rejection 打崩宿主(与 _probeOnce 同律)
|
|
1116
|
-
this.probeDataPlaneOnce().catch((err) => {
|
|
1117
|
-
this._probeLog('warn', '数据面探测出现未预期异常(已忽略,不影响连接器运行): %s', err?.message ?? err);
|
|
1118
|
-
});
|
|
1119
|
-
};
|
|
1120
|
-
tick();
|
|
1121
|
-
this._dataPlaneTimer = setInterval(tick, this.dataPlaneProbeIntervalMs);
|
|
1122
|
-
this._dataPlaneTimer.unref?.(); // 不因探针定时器而阻止进程退出
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
_stopDataPlaneProbe() {
|
|
1126
|
-
if (this._dataPlaneTimer) {
|
|
1127
|
-
clearInterval(this._dataPlaneTimer);
|
|
1128
|
-
this._dataPlaneTimer = null;
|
|
1129
|
-
}
|
|
1130
|
-
// 即使定时器已空也要作废飞行中的结果(stop 可能发生在 tick 执行期间)
|
|
1131
|
-
this._dataPlaneEpoch += 1;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/** 数据面状态快照(getStatus 只读取) */
|
|
1135
|
-
dataPlaneState() {
|
|
1136
|
-
return { ...this._dataPlane };
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
async _probeOnce() {
|
|
1140
|
-
// 防重叠:单次探活最长 healthProbeTimeoutMs,若上一轮还没结论就不再叠加请求
|
|
1141
|
-
if (this._probeInFlight) return;
|
|
1142
|
-
const addr = this._metricsAddress;
|
|
1143
|
-
if (!addr) return;
|
|
1144
|
-
const seq = this._spawnSeq; // spawn 代数:据此识别"结果属于哪个进程"
|
|
1145
|
-
|
|
1146
|
-
this._probeInFlight = true;
|
|
1147
|
-
let result;
|
|
1148
|
-
try {
|
|
1149
|
-
result = await this._checkTunnelReady(addr);
|
|
1150
|
-
} catch (err) {
|
|
1151
|
-
// 兜底:探针内部任何异常都不得向外逃逸(见 setInterval 处的说明)
|
|
1152
|
-
result = { healthy: false, reason: `探针内部异常: ${err?.message ?? err}` };
|
|
1153
|
-
} finally {
|
|
1154
|
-
this._probeInFlight = false;
|
|
1155
|
-
}
|
|
1156
|
-
this._probeCount++;
|
|
1157
|
-
|
|
1158
|
-
// 探活期间可能已被 stop()、或已自愈重启到新进程 → 结果作废。
|
|
1159
|
-
// 必须比对代数而非地址:重启后 cloudflared 很可能复用同一个 metrics 端口。
|
|
1160
|
-
if (this._stopped || this._spawnSeq !== seq) return;
|
|
1161
|
-
|
|
1162
|
-
if (result.healthy) {
|
|
1163
|
-
if (this._probeFailures >= this.healthDegradedThreshold) {
|
|
1164
|
-
this._probeLog('info', '隧道健康探活已自行恢复(此前连续失败 %d 次)', this._probeFailures);
|
|
1165
|
-
if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
|
|
1166
|
-
}
|
|
1167
|
-
this._probeFailures = 0;
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
this._probeFailures++;
|
|
1172
|
-
const fails = this._probeFailures;
|
|
1173
|
-
const why = result.reason || '未知原因';
|
|
1174
|
-
|
|
1175
|
-
if (fails === this.healthDegradedThreshold) {
|
|
1176
|
-
// 第一级:可见降级。此时不杀进程——/ready 为 503 也可能只是 cloudflared
|
|
1177
|
-
// 正在按自身退避重连,过早介入反而会把本可自愈的故障变成人工故障。
|
|
1178
|
-
this._probeLog('warn', '隧道健康探活连续 %d 次失败(%s):连接器可能已无健康边缘连接,'
|
|
1179
|
-
+ '转入降级观察,达 %d 次将强制重建', fails, why, this.healthRestartThreshold);
|
|
1180
|
-
this._setState('reconnecting', `健康探活连续 ${fails} 次失败:连接器已无健康边缘连接,`
|
|
1181
|
-
+ `正在观察能否自行恢复(${fails}/${this.healthRestartThreshold})`);
|
|
1182
|
-
} else if (fails < this.healthDegradedThreshold) {
|
|
1183
|
-
this._probeLog('warn', '隧道健康探活失败(第 %d/%d 次): %s;原因: %s',
|
|
1184
|
-
fails, this.healthRestartThreshold, addr, why);
|
|
1185
|
-
} else {
|
|
1186
|
-
this._probeLog('warn', '隧道健康探活仍失败(第 %d/%d 次): %s', fails, this.healthRestartThreshold, why);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
if (fails < this.healthRestartThreshold) return;
|
|
1190
|
-
|
|
1191
|
-
// 第二级:持续不恢复 → 判定假死,强制重建。
|
|
1192
|
-
// 置 _healthRecovering:整条重建链路(含重启后握手失败)都不允许进入终态。
|
|
1193
|
-
this._probeLog('error', '隧道连接器连续 %d 次探活失败(约 %d 分钟):判定为假死,'
|
|
1194
|
-
+ '终止进程以触发重建;该重建不设终态上限,网络恢复后仍会自愈',
|
|
1195
|
-
fails, Math.round(fails * this.healthProbeIntervalMs / 60000));
|
|
1196
|
-
this._stopHealthProbe();
|
|
1197
|
-
this._healthRecovering = true;
|
|
1198
|
-
this._terminateProcess(); // 不自行重启:交给 exit → _scheduleRestart 既有链路
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// 探活判据:HTTP 200 且 readyConnections > 0(与 cloudflared 官方 /ready 语义一致)。
|
|
1202
|
-
// 返回 { healthy, reason }:reason 会进入 warn 级日志,生产关闭 debug 时也能看到原因。
|
|
1203
|
-
// 注意:http 的 timeout 选项只是 **socket 不活动** 超时,不是总时限——对"持续滴流
|
|
1204
|
-
// 字节"的响应永不触发,因此这里额外加一个绝对截止时间兜底。
|
|
1205
|
-
_checkTunnelReady(addr) {
|
|
1206
|
-
return new Promise((resolve) => {
|
|
1207
|
-
let settled = false;
|
|
1208
|
-
let deadline = null;
|
|
1209
|
-
let req = null;
|
|
1210
|
-
const done = (healthy, reason) => {
|
|
1211
|
-
if (settled) return;
|
|
1212
|
-
settled = true;
|
|
1213
|
-
if (deadline) clearTimeout(deadline);
|
|
1214
|
-
try { req?.destroy(); } catch { /* 已结束的请求 destroy 无害 */ }
|
|
1215
|
-
resolve({ healthy, reason });
|
|
1216
|
-
};
|
|
1217
|
-
|
|
1218
|
-
deadline = setTimeout(() => {
|
|
1219
|
-
done(false, `探活超过总时限 ${this.healthProbeTimeoutMs}ms`);
|
|
1220
|
-
}, this.healthProbeTimeoutMs);
|
|
1221
|
-
|
|
1222
|
-
// httpGet 对畸形 URL 会同步抛错;虽然 parseMetricsAddress 已在入口校验,
|
|
1223
|
-
// 这里再兜一层,确保该函数永不向外抛(否则会变成未处理 rejection 拖垮主进程)。
|
|
1224
|
-
try {
|
|
1225
|
-
req = httpGet(`http://${addr}/ready`, { timeout: this.healthProbeTimeoutMs }, (res) => {
|
|
1226
|
-
let body = '';
|
|
1227
|
-
res.setEncoding('utf8');
|
|
1228
|
-
res.on('data', (chunk) => { body += chunk; });
|
|
1229
|
-
res.on('end', () => {
|
|
1230
|
-
if (res.statusCode !== 200) {
|
|
1231
|
-
done(false, `HTTP ${res.statusCode}`);
|
|
1232
|
-
return;
|
|
1233
|
-
}
|
|
1234
|
-
try {
|
|
1235
|
-
const parsed = JSON.parse(body);
|
|
1236
|
-
const conns = Number(parsed?.readyConnections);
|
|
1237
|
-
done(conns > 0, conns > 0 ? '' : `HTTP 200 但 readyConnections=${parsed?.readyConnections}`);
|
|
1238
|
-
} catch (err) {
|
|
1239
|
-
done(false, `响应无法解析为 JSON: ${err.message}`);
|
|
1240
|
-
}
|
|
1241
|
-
});
|
|
1242
|
-
});
|
|
1243
|
-
} catch (err) {
|
|
1244
|
-
done(false, `无法发起探活请求(地址可能非法): ${err.message}`);
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
req.on('timeout', () => { done(false, `socket 不活动超过 ${this.healthProbeTimeoutMs}ms`); });
|
|
1248
|
-
req.on('error', (err) => { done(false, `请求失败: ${err.message}`); });
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
_setState(phase, detail, { record = true } = {}) {
|
|
1253
|
-
// 记住最近一次"纯净"的 ready 状态:探活由降级恢复、或地址迟到后补启探活时
|
|
1254
|
-
// 要回写它,避免面板长期停留在降级文案上(record:false 用于降级提示本身)。
|
|
1255
|
-
if (phase === 'ready' && record) this._readyState = { phase, detail };
|
|
1256
|
-
this.onStateChange?.({ phase, detail });
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
stop() {
|
|
1260
|
-
this._stopped = true;
|
|
1261
|
-
if (this._retryTimer) {
|
|
1262
|
-
clearTimeout(this._retryTimer);
|
|
1263
|
-
this._retryTimer = null;
|
|
1264
|
-
}
|
|
1265
|
-
this._restartCount = 0;
|
|
1266
|
-
this._stopHealthProbe();
|
|
1267
|
-
this._stopDataPlaneProbe();
|
|
1268
|
-
this._closeLogStream();
|
|
1269
|
-
if (this.process) {
|
|
1270
|
-
this.logger?.info('停止 cloudflared...');
|
|
1271
|
-
this._terminateProcess();
|
|
1272
|
-
this.process = null;
|
|
1273
|
-
}
|
|
1274
|
-
this.url = null;
|
|
1275
|
-
this._setState('idle', '');
|
|
1276
|
-
}
|
|
1277
|
-
}
|