@shendeguize/remote-dsh-center 0.4.0
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/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
package/src/tunnel.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh -L 隧道子进程与本机直连条目管理(11 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 三条边界:
|
|
5
|
+
* 1. 子进程生命周期与退避重连全在本模块(monitor 只做周期探活,见 11 §5.5)。
|
|
6
|
+
* 2. 不 import launcher(防环规则 3):重连前的远端复核由本模块经 proto + ssh 自理。
|
|
7
|
+
* 3. 隧道存活 == 子进程存活(不用 ssh -f),故 kill 子进程即断隧道。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
import { logEvent } from './lib/bus.js';
|
|
14
|
+
import { DshError } from './lib/errors.js';
|
|
15
|
+
import { buildVerifyScript, kvOne, parseProtoOutput } from './lib/proto.js';
|
|
16
|
+
import { createGate } from './lib/pool.js';
|
|
17
|
+
import { assertSafeHost } from './lib/shq.js';
|
|
18
|
+
import { TUNNEL_SSH_OPTS, execFailure, hostQueue, sshBin, sshExec } from './lib/ssh.js';
|
|
19
|
+
import { monotonicMs } from './lib/clock.js';
|
|
20
|
+
import { SSH_FANOUT_LIMIT } from './defaults.js';
|
|
21
|
+
import * as store from './store.js';
|
|
22
|
+
|
|
23
|
+
export const TUNNEL_TIMING = Object.freeze({
|
|
24
|
+
readyTimeoutMs: 8_000, // §5.2:8s 未就绪即失败
|
|
25
|
+
readyPollMs: 250,
|
|
26
|
+
connectProbeMs: 1_000,
|
|
27
|
+
killGraceMs: 2_000,
|
|
28
|
+
denyWindowMs: 60_000, // §5.3 修正:运行中 stderr 拒绝行的统计窗口
|
|
29
|
+
denyThreshold: 3,
|
|
30
|
+
stderrTailBytes: 4_096,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* §5.4 的 1s,2s,4s,8s,16s,30s,30s… 作为**上界**,实际在半程到满程之间随机取
|
|
35
|
+
* (issue #100)。
|
|
36
|
+
*
|
|
37
|
+
* 为什么必须抖:一起断的主机会一起退避、一起重试。确定值意味着它们从头到尾锁着步,
|
|
38
|
+
* 每一轮都同时撞在跳板机的 `MaxStartups` 上,被拒之后又一起进下一档——永远散不开。
|
|
39
|
+
* 实测 16 台同时断,70 秒后仍有 6 台卡在 degraded,且 attempt 完全相同。
|
|
40
|
+
*
|
|
41
|
+
* 取半程而非全程(full jitter):全程会让「刚断一下」的常见情形偶尔等满 30s,
|
|
42
|
+
* 体感变差;半程既散得开,又保住了「退避大致翻倍」的可预期节奏。
|
|
43
|
+
* @param {number} attempt
|
|
44
|
+
* @param {() => number} [rand] 注入点:单测要确定值
|
|
45
|
+
*/
|
|
46
|
+
export function backoffDelay(attempt, rand = Math.random) {
|
|
47
|
+
const ceiling = Math.min(1000 * 2 ** attempt, 30_000);
|
|
48
|
+
return Math.round(ceiling * (0.5 + 0.5 * rand()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const PORT_BUSY_RE = /address already in use|cannot listen to port/i;
|
|
52
|
+
const FORWARD_DENIED_RE = /open failed: administratively prohibited|forwarding\b.*(failed|denied|disabled)/i;
|
|
53
|
+
|
|
54
|
+
/** 运行中 stderr 的单行判据(§5.3 第 3 行),与退出分类共用。 */
|
|
55
|
+
export function isForwardDeniedLine(line) {
|
|
56
|
+
return FORWARD_DENIED_RE.test(line);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* §5.3 退出分类(纯函数,喂样本可单测)。优先级:expected > local-port-busy >
|
|
61
|
+
* forward-disabled > network。
|
|
62
|
+
* @param {{killedByUs?:boolean, forcedReason?:string|null, stderrTail?:string}} p
|
|
63
|
+
* @returns {'expected'|'local-port-busy'|'forward-disabled'|'network'}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyExit({ killedByUs = false, forcedReason = null, stderrTail = '' } = {}) {
|
|
66
|
+
if (killedByUs) return 'expected';
|
|
67
|
+
if (forcedReason) return /** @type {any} */ (forcedReason);
|
|
68
|
+
if (PORT_BUSY_RE.test(stderrTail)) return 'local-port-busy';
|
|
69
|
+
if (FORWARD_DENIED_RE.test(stderrTail)) return 'forward-disabled';
|
|
70
|
+
return 'network';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SUSPEND_REASONS = { 'local-port-busy': 'TUNNEL_PORT_BUSY', 'forward-disabled': 'TUNNEL_FORWARD_DISABLED' };
|
|
74
|
+
|
|
75
|
+
const SUSPEND_HINT = {
|
|
76
|
+
'local-port-busy': '本机端口已被其他进程占用:释放该端口,或改 config.hosts.<name>.localPort 后重连',
|
|
77
|
+
'forward-disabled': '远端 sshd 禁止端口转发(AllowTcpForwarding no):需远端放开后重连',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {{host:string, localPort:number, remotePort:number, direct:boolean,
|
|
82
|
+
* child:import('node:child_process').ChildProcess|null,
|
|
83
|
+
* killedByUs:boolean, forcedReason:string|null, connected:boolean, attempt:number,
|
|
84
|
+
* suspendedReason:string|null, stderrTail:string, denyStamps:number[],
|
|
85
|
+
* retryTimer:NodeJS.Timeout|null, readyPending:boolean, closed:boolean}} Entry
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/** @type {Map<string, Entry>} */
|
|
89
|
+
const entries = new Map();
|
|
90
|
+
|
|
91
|
+
/** 重连环的扇出闸(issue #100)。与 #85 的批量扇出共用同一个额度口径。 */
|
|
92
|
+
const reconnectGate = createGate(SSH_FANOUT_LIMIT);
|
|
93
|
+
|
|
94
|
+
function entry(name) {
|
|
95
|
+
return entries.get(name) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns {{localPort:number, connected:boolean, reconnectAttempt:number,
|
|
99
|
+
* suspendedReason:string|null, direct?:true}|null} */
|
|
100
|
+
export function status(name) {
|
|
101
|
+
const e = entry(name);
|
|
102
|
+
if (!e || e.closed) return null;
|
|
103
|
+
return {
|
|
104
|
+
localPort: e.localPort,
|
|
105
|
+
connected: e.connected,
|
|
106
|
+
reconnectAttempt: e.attempt,
|
|
107
|
+
suspendedReason: e.suspendedReason,
|
|
108
|
+
...(e.direct ? { direct: true } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isOpen(name) {
|
|
113
|
+
const e = entry(name);
|
|
114
|
+
if (!e || e.closed) return false;
|
|
115
|
+
if (e.direct) return true;
|
|
116
|
+
return Boolean(e.child && e.child.exitCode === null && e.child.signalCode === null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function listOpen() {
|
|
120
|
+
return [...entries.keys()].filter((n) => isOpen(n));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 子进程 ───────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function spawnChild(e) {
|
|
126
|
+
assertSafeHost(e.host);
|
|
127
|
+
const { bin, prefixArgs } = sshBin();
|
|
128
|
+
const forward = `127.0.0.1:${e.localPort}:127.0.0.1:${e.remotePort}`;
|
|
129
|
+
const args = [...prefixArgs, '-N', '-L', forward, ...TUNNEL_SSH_OPTS, e.host];
|
|
130
|
+
|
|
131
|
+
e.stderrTail = '';
|
|
132
|
+
e.denyStamps = [];
|
|
133
|
+
e.killedByUs = false;
|
|
134
|
+
e.forcedReason = null;
|
|
135
|
+
|
|
136
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
137
|
+
e.child = child;
|
|
138
|
+
|
|
139
|
+
child.stderr.setEncoding('utf8');
|
|
140
|
+
child.stderr.on('data', (chunk) => onStderr(e, chunk));
|
|
141
|
+
child.on('error', (err) => {
|
|
142
|
+
e.stderrTail = `${e.stderrTail}\n${err.message}`.slice(-TUNNEL_TIMING.stderrTailBytes);
|
|
143
|
+
});
|
|
144
|
+
child.on('exit', (code, signal) => {
|
|
145
|
+
if (e.child !== child) return; // 已被 restartChild 换代,旧回调作废
|
|
146
|
+
e.child = null;
|
|
147
|
+
e.connected = false;
|
|
148
|
+
onExit(e, code, signal);
|
|
149
|
+
});
|
|
150
|
+
return child;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 运行中 stderr:既累积尾部供退出分类,又按 §5.3 修正统计转发被拒次数。 */
|
|
154
|
+
function onStderr(e, chunk) {
|
|
155
|
+
e.stderrTail = (e.stderrTail + chunk).slice(-TUNNEL_TIMING.stderrTailBytes);
|
|
156
|
+
const now = monotonicMs(); // 窗口是「过了多久」,不能用墙钟(issue #104)
|
|
157
|
+
for (const line of String(chunk).split('\n')) {
|
|
158
|
+
if (line.trim() === '' || !isForwardDeniedLine(line)) continue;
|
|
159
|
+
e.denyStamps.push(now);
|
|
160
|
+
}
|
|
161
|
+
e.denyStamps = e.denyStamps.filter((t) => now - t <= TUNNEL_TIMING.denyWindowMs);
|
|
162
|
+
if (e.denyStamps.length >= TUNNEL_TIMING.denyThreshold && e.forcedReason === null) {
|
|
163
|
+
// 表面 running 实则不可用:主动杀子进程,转挂起态并明示原因
|
|
164
|
+
e.forcedReason = 'forward-disabled';
|
|
165
|
+
logEvent(e.host, 'error', `隧道转发被远端拒绝 ${e.denyStamps.length} 次,判定为 forward-disabled`, e.stderrTail);
|
|
166
|
+
killChild(e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function killChild(e) {
|
|
171
|
+
const child = e.child;
|
|
172
|
+
if (!child) return;
|
|
173
|
+
child.kill('SIGTERM');
|
|
174
|
+
const t = setTimeout(() => {
|
|
175
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
176
|
+
}, TUNNEL_TIMING.killGraceMs);
|
|
177
|
+
t.unref?.();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 单次 TCP 探活:只证明「ssh 已在本机监听」,即隧道已建立。 */
|
|
181
|
+
export function probeLocalPort(port, timeoutMs = TUNNEL_TIMING.connectProbeMs) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const sock = net.connect({ port, host: '127.0.0.1' });
|
|
184
|
+
const done = (ok) => {
|
|
185
|
+
sock.removeAllListeners();
|
|
186
|
+
sock.destroy();
|
|
187
|
+
resolve(ok);
|
|
188
|
+
};
|
|
189
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
190
|
+
sock.once('connect', () => done(true));
|
|
191
|
+
sock.once('error', () => done(false));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 转发通道探活(monitor 用)。
|
|
197
|
+
*
|
|
198
|
+
* connect 成功只说明 ssh 在监听:远端实例死掉之后 ssh 照样 accept,随即因为
|
|
199
|
+
* channel open 失败把连接掐断。所以必须发一个最小请求、等到第一个字节回来,
|
|
200
|
+
* 才算这条转发真的能载数据;accept 后无字节即断 == 远端不在了。
|
|
201
|
+
*/
|
|
202
|
+
export function probeForward(port, timeoutMs = TUNNEL_TIMING.connectProbeMs) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
const sock = net.connect({ port, host: '127.0.0.1' });
|
|
205
|
+
const done = (ok) => {
|
|
206
|
+
sock.removeAllListeners();
|
|
207
|
+
sock.destroy();
|
|
208
|
+
resolve(ok);
|
|
209
|
+
};
|
|
210
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
211
|
+
sock.once('connect', () => sock.write('HEAD / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n'));
|
|
212
|
+
sock.once('data', () => done(true));
|
|
213
|
+
sock.once('end', () => done(false));
|
|
214
|
+
sock.once('close', () => done(false));
|
|
215
|
+
sock.once('error', () => done(false));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* §5.2 就绪确认:`ssh -N` 成功无输出,判据是本地监听可连。
|
|
221
|
+
* 子进程提前退出 → 按 §5.3 分类抛错;8s 未就绪 → 杀子进程并失败。
|
|
222
|
+
*/
|
|
223
|
+
async function waitReady(e) {
|
|
224
|
+
e.readyPending = true;
|
|
225
|
+
const deadline = monotonicMs() + TUNNEL_TIMING.readyTimeoutMs;
|
|
226
|
+
try {
|
|
227
|
+
for (;;) {
|
|
228
|
+
if (e.child === null) {
|
|
229
|
+
const kind = classifyExit(e);
|
|
230
|
+
throw tunnelError(e, kind);
|
|
231
|
+
}
|
|
232
|
+
// eslint-disable-next-line no-await-in-loop -- 轮询就绪,语义上必须顺序
|
|
233
|
+
if (await probeLocalPort(e.localPort, TUNNEL_TIMING.readyPollMs)) {
|
|
234
|
+
e.connected = true;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (monotonicMs() >= deadline) {
|
|
238
|
+
e.killedByUs = true;
|
|
239
|
+
killChild(e);
|
|
240
|
+
throw new DshError('INTERNAL', `隧道 ${TUNNEL_TIMING.readyTimeoutMs}ms 内未就绪(${e.host})`, {
|
|
241
|
+
host: e.host,
|
|
242
|
+
detail: e.stderrTail || null,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// eslint-disable-next-line no-await-in-loop -- 同上
|
|
246
|
+
await sleep(TUNNEL_TIMING.readyPollMs);
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
e.readyPending = false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function tunnelError(e, kind) {
|
|
254
|
+
const code = SUSPEND_REASONS[kind] ?? 'INTERNAL';
|
|
255
|
+
const msg = {
|
|
256
|
+
'local-port-busy': `本机端口 ${e.localPort} 已被占用,隧道无法建立`,
|
|
257
|
+
'forward-disabled': `远端拒绝端口转发(${e.host})`,
|
|
258
|
+
network: `隧道进程异常退出(${e.host})`,
|
|
259
|
+
expected: `隧道已被主动关闭(${e.host})`,
|
|
260
|
+
}[kind];
|
|
261
|
+
return new DshError(code, msg, { host: e.host, detail: e.stderrTail || null });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
|
|
265
|
+
|
|
266
|
+
// ── 打开 / 关闭 ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 远端 spawn + 就绪确认;本机只登记恒等映射的直连条目。
|
|
270
|
+
* 失败抛 DshError,并保证不留残留子进程。
|
|
271
|
+
* @param {string} name
|
|
272
|
+
* @param {{localPort:number, remotePort:number, direct?:boolean}} p
|
|
273
|
+
*/
|
|
274
|
+
export async function open(name, { localPort, remotePort, direct = false }) {
|
|
275
|
+
if (
|
|
276
|
+
direct
|
|
277
|
+
&& (
|
|
278
|
+
!Number.isInteger(localPort)
|
|
279
|
+
|| localPort < 1
|
|
280
|
+
|| localPort > 65_535
|
|
281
|
+
|| localPort !== remotePort
|
|
282
|
+
)
|
|
283
|
+
) {
|
|
284
|
+
throw new DshError(
|
|
285
|
+
'VALIDATION',
|
|
286
|
+
'本机直连端口必须是有效端口,且与 dsh web 实际端口一致',
|
|
287
|
+
{ host: name },
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const prev = entry(name);
|
|
292
|
+
if (prev) await close(name);
|
|
293
|
+
|
|
294
|
+
/** @type {Entry} */
|
|
295
|
+
const e = {
|
|
296
|
+
host: name,
|
|
297
|
+
localPort,
|
|
298
|
+
remotePort,
|
|
299
|
+
direct,
|
|
300
|
+
child: null,
|
|
301
|
+
killedByUs: false,
|
|
302
|
+
forcedReason: null,
|
|
303
|
+
connected: direct,
|
|
304
|
+
attempt: 0,
|
|
305
|
+
suspendedReason: null,
|
|
306
|
+
stderrTail: '',
|
|
307
|
+
denyStamps: [],
|
|
308
|
+
retryTimer: null,
|
|
309
|
+
readyPending: false,
|
|
310
|
+
closed: false,
|
|
311
|
+
};
|
|
312
|
+
entries.set(name, e);
|
|
313
|
+
|
|
314
|
+
if (!direct) {
|
|
315
|
+
spawnChild(e);
|
|
316
|
+
try {
|
|
317
|
+
await waitReady(e);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
e.closed = true;
|
|
320
|
+
entries.delete(name);
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
store.mutateHostState(name, (st) => {
|
|
326
|
+
st.tunnel = {
|
|
327
|
+
localPort,
|
|
328
|
+
remotePort,
|
|
329
|
+
...(direct ? { direct: true } : {}),
|
|
330
|
+
openedAt: new Date().toISOString(),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
logEvent(
|
|
334
|
+
name,
|
|
335
|
+
'info',
|
|
336
|
+
direct
|
|
337
|
+
? `本机直连已登记 127.0.0.1:${localPort}`
|
|
338
|
+
: `隧道就绪 127.0.0.1:${localPort} → 远端 ${remotePort}`,
|
|
339
|
+
);
|
|
340
|
+
return status(name);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 主动关闭:打 expected 标记(不触发重连),清定时器。 */
|
|
344
|
+
export async function close(name) {
|
|
345
|
+
const e = entry(name);
|
|
346
|
+
if (!e) return;
|
|
347
|
+
e.closed = true;
|
|
348
|
+
clearRetry(e);
|
|
349
|
+
entries.delete(name);
|
|
350
|
+
if (e.direct) {
|
|
351
|
+
e.connected = false;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
e.killedByUs = true;
|
|
356
|
+
const child = e.child;
|
|
357
|
+
killChild(e);
|
|
358
|
+
if (child) await waitExit(child);
|
|
359
|
+
e.connected = false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** manager 退出/自我重启:只杀本机子进程,不动远端。 */
|
|
363
|
+
export async function closeAll() {
|
|
364
|
+
await Promise.all([...entries.keys()].map((n) => close(n)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 收掉「已经不在 config 里」的那些隧道(issue #96)。
|
|
369
|
+
*
|
|
370
|
+
* 主机离开 `config.hosts` 之后(手改配置后 reload,或重跑 setup——它是整份替换),
|
|
371
|
+
* 页面上就看不见它了,`stop`/`reconnect` 一律 404。可这条隧道 ssh 还活着、本机端口
|
|
372
|
+
* 还在转发,而**没有任何入口能碰到它**——那是 manager 自己起的子进程,不收就是泄漏。
|
|
373
|
+
*
|
|
374
|
+
* 只收本机这半边。远端实例不动:删一条配置不等于「把远端那个服务停掉」,按「不误杀」
|
|
375
|
+
* 的底线,没有明确指令就不碰远端;state 记录也留着,主机加回来时还能接管它。
|
|
376
|
+
* 但要在日志里说清远端还在跑、pid 多少——否则用户以为删干净了。
|
|
377
|
+
* @returns {Promise<string[]>} 收掉的主机名
|
|
378
|
+
*/
|
|
379
|
+
export async function closeUnconfigured() {
|
|
380
|
+
const known = new Set(store.listHostNames());
|
|
381
|
+
const gone = [...entries.keys()].filter((n) => !known.has(n));
|
|
382
|
+
for (const name of gone) {
|
|
383
|
+
const e = entry(name);
|
|
384
|
+
const port = e?.localPort ?? null;
|
|
385
|
+
const direct = e?.direct === true;
|
|
386
|
+
// eslint-disable-next-line no-await-in-loop -- 逐条收,数量与「刚被删掉的主机数」同阶
|
|
387
|
+
await close(name);
|
|
388
|
+
const pid = store.getHostState(name)?.web?.pid ?? null;
|
|
389
|
+
logEvent(
|
|
390
|
+
name,
|
|
391
|
+
'warn',
|
|
392
|
+
direct
|
|
393
|
+
? `${name} 已不在配置里,已清除本机直连记录(web 端口 ${port ?? '(未知)'})`
|
|
394
|
+
+ (pid ? `;本机实例还在跑(pid=${pid}),没有动它` : '')
|
|
395
|
+
: `${name} 已不在配置里,已收回它占的本机端口 ${port ?? '(未知)'}`
|
|
396
|
+
+ (pid ? `;远端实例还在跑(pid=${pid}),没有动它` : ''),
|
|
397
|
+
pid
|
|
398
|
+
? direct
|
|
399
|
+
? '删一条配置不等于要停掉本机受管服务,所以此处不替你杀进程。'
|
|
400
|
+
+ '想停掉:把这台主机加回配置再关停,或者手工处理。'
|
|
401
|
+
: '删一条配置不等于要停掉远端那个服务,所以此处不替你杀远端进程。'
|
|
402
|
+
+ '想停掉:把这台主机加回配置再关停,或者直接去远端处理。'
|
|
403
|
+
: null,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return gone;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** monitor 专用:杀 + 重建子进程,不改 phase。 */
|
|
410
|
+
export async function restartChild(name) {
|
|
411
|
+
const e = entry(name);
|
|
412
|
+
if (!e) throw new DshError('NOT_FOUND', `主机 ${name} 无隧道记录`, { host: name });
|
|
413
|
+
if (e.direct) {
|
|
414
|
+
throw new DshError('NOT_ALLOWED', '本机主机使用直连,没有隧道子进程可重建', { host: name });
|
|
415
|
+
}
|
|
416
|
+
e.killedByUs = true;
|
|
417
|
+
const old = e.child;
|
|
418
|
+
killChild(e);
|
|
419
|
+
e.child = null;
|
|
420
|
+
if (old) await waitExit(old);
|
|
421
|
+
|
|
422
|
+
spawnChild(e);
|
|
423
|
+
await waitReady(e);
|
|
424
|
+
logEvent(name, 'info', '隧道子进程已重建');
|
|
425
|
+
return status(name);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function waitExit(child, timeoutMs = TUNNEL_TIMING.killGraceMs + 1_000) {
|
|
429
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
const t = setTimeout(resolve, timeoutMs);
|
|
432
|
+
t.unref?.();
|
|
433
|
+
child.once('exit', () => { clearTimeout(t); resolve(); });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function clearRetry(e) {
|
|
438
|
+
if (e.retryTimer) {
|
|
439
|
+
clearTimeout(e.retryTimer);
|
|
440
|
+
e.retryTimer = null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── 意外退出 → 分流(§5.3 / 3.3) ────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function onExit(e, code, signal) {
|
|
447
|
+
if (e.closed) return;
|
|
448
|
+
if (e.readyPending) return; // 就绪等待中的失败由 waitReady 统一处理
|
|
449
|
+
|
|
450
|
+
const kind = classifyExit(e);
|
|
451
|
+
if (kind === 'expected') return;
|
|
452
|
+
|
|
453
|
+
degrade(e, `隧道断开:${kind}(退出码 ${code ?? signal})`);
|
|
454
|
+
|
|
455
|
+
if (kind === 'local-port-busy' || kind === 'forward-disabled') {
|
|
456
|
+
e.suspendedReason = kind;
|
|
457
|
+
logEvent(e.host, 'error', `隧道挂起(${kind}):${SUSPEND_HINT[kind]}`, e.stderrTail || null);
|
|
458
|
+
store.mutateHostState(e.host, (st) => { st.tunnel = null; });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
scheduleRetry(e);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** running → degraded;其它 phase(stop 收尾、starting 回滚)不动。 */
|
|
465
|
+
function degrade(e, msg) {
|
|
466
|
+
const phase = store.getPhase(e.host);
|
|
467
|
+
logEvent(e.host, 'warn', msg, e.stderrTail || null);
|
|
468
|
+
if (phase === 'running') store.setPhase(e.host, 'degraded', 'tunnel.onExit');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function scheduleRetry(e) {
|
|
472
|
+
clearRetry(e);
|
|
473
|
+
const delay = backoffDelay(e.attempt);
|
|
474
|
+
e.retryTimer = setTimeout(() => {
|
|
475
|
+
e.retryTimer = null;
|
|
476
|
+
reconnectTick(e).catch((err) => {
|
|
477
|
+
logEvent(e.host, 'warn', `重连一拍异常:${err.message}`, err.detail ?? null);
|
|
478
|
+
if (!e.closed && e.suspendedReason === null) {
|
|
479
|
+
e.attempt += 1;
|
|
480
|
+
scheduleRetry(e);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}, delay);
|
|
484
|
+
e.retryTimer.unref?.();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 一拍 = 远端复核(经 hostQueue,与用户操作串行)→ spawn → 就绪确认。
|
|
489
|
+
* 远端死/指纹不符 → crashed 并退出环(02 §3.3)。
|
|
490
|
+
*/
|
|
491
|
+
async function reconnectTick(e) {
|
|
492
|
+
if (e.closed || e.suspendedReason !== null) return;
|
|
493
|
+
// 每台主机各有一个退避定时器,网络整体回来时它们会挤在同一刻。#85 给探测/恢复/自启/
|
|
494
|
+
// 巡检都上了闸,唯独这个环没有——而它正是那一瞬最先集体触发的(issue #100)。
|
|
495
|
+
return reconnectGate.run(() => reconnectBody(e));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function reconnectBody(e) {
|
|
499
|
+
// 排队期间关停可能已经过去了
|
|
500
|
+
if (e.closed || e.suspendedReason !== null) return;
|
|
501
|
+
|
|
502
|
+
const alive = await verifyRemoteAlive(e.host);
|
|
503
|
+
if (alive === false) {
|
|
504
|
+
store.mutateHostState(e.host, (st) => { st.tunnel = null; });
|
|
505
|
+
clearRetry(e);
|
|
506
|
+
e.closed = true;
|
|
507
|
+
entries.delete(e.host);
|
|
508
|
+
const phase = store.getPhase(e.host);
|
|
509
|
+
if (phase === 'degraded' || phase === 'running') store.setPhase(e.host, 'crashed', 'tunnel.reconnect');
|
|
510
|
+
logEvent(e.host, 'error', '重连前复核失败:远端实例已消失或指纹不符,标记 crashed');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 复核那一步是要等远端的,这段等待里 close/closeAll 可能已经过去了(关停、stop)。
|
|
515
|
+
// 不复查就会在关停之后再 spawn 一条隧道 ssh——没人再来收它,成孤儿(issue #74)。
|
|
516
|
+
if (e.closed || e.suspendedReason !== null) return;
|
|
517
|
+
|
|
518
|
+
logEvent(e.host, 'info', `隧道重连尝试 ${e.attempt + 1}`);
|
|
519
|
+
spawnChild(e);
|
|
520
|
+
try {
|
|
521
|
+
await waitReady(e);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (e.closed) return;
|
|
524
|
+
if (err.code === 'TUNNEL_PORT_BUSY' || err.code === 'TUNNEL_FORWARD_DISABLED') {
|
|
525
|
+
e.suspendedReason = err.code === 'TUNNEL_PORT_BUSY' ? 'local-port-busy' : 'forward-disabled';
|
|
526
|
+
logEvent(e.host, 'error', `隧道挂起(${e.suspendedReason}):${SUSPEND_HINT[e.suspendedReason]}`, err.detail);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
e.attempt += 1;
|
|
530
|
+
scheduleRetry(e);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
e.attempt = 0;
|
|
535
|
+
store.mutateHostState(e.host, (st) => {
|
|
536
|
+
st.tunnel = { localPort: e.localPort, remotePort: e.remotePort, openedAt: new Date().toISOString() };
|
|
537
|
+
});
|
|
538
|
+
logEvent(e.host, 'info', '隧道已恢复');
|
|
539
|
+
if (store.getPhase(e.host) === 'degraded') store.setPhase(e.host, 'running', 'tunnel.reconnect');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 重连前复核(02 §3.3)。本模块自理,不 import launcher(防环规则 3)。
|
|
544
|
+
* @returns {Promise<boolean|null>} true=活且指纹符;false=死或指纹不符;null=无从判断(无 web 记录/ssh 故障)
|
|
545
|
+
*/
|
|
546
|
+
async function verifyRemoteAlive(name) {
|
|
547
|
+
const web = store.getHostState(name)?.web;
|
|
548
|
+
if (!web?.pid || !web?.cmdFingerprint) return null;
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
return await hostQueue(name).run('tunnel-verify', async (signal) => {
|
|
552
|
+
const res = await sshExec(name, buildVerifyScript({ pid: web.pid, port: web.port ?? 1 }), { signal });
|
|
553
|
+
if (execFailure(name, '重连前复核', res)) return null; // ssh 层故障:不定罪远端,继续重连
|
|
554
|
+
const out = parseProtoOutput(res.stdout, { requireDone: 'VERIFY_DONE' });
|
|
555
|
+
if (kvOne(out, 'ALIVE') !== 'yes') return false;
|
|
556
|
+
return (out.blocks.ARGS ?? null) === web.cmdFingerprint;
|
|
557
|
+
});
|
|
558
|
+
} catch {
|
|
559
|
+
return null; // 队列超时/解析失败:同上,宁可多试一拍
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** POST /reconnect:清挂起、attempt 归零、立即试一拍。 */
|
|
564
|
+
export async function requestReconnect(name) {
|
|
565
|
+
const e = entry(name);
|
|
566
|
+
if (!e || e.closed) {
|
|
567
|
+
throw new DshError('NOT_FOUND', `主机 ${name} 当前无隧道可重连(请先启动)`, { host: name });
|
|
568
|
+
}
|
|
569
|
+
if (e.direct) {
|
|
570
|
+
throw new DshError('NOT_ALLOWED', '本机主机使用直连,没有隧道可重连', { host: name });
|
|
571
|
+
}
|
|
572
|
+
e.suspendedReason = null;
|
|
573
|
+
e.attempt = 0;
|
|
574
|
+
clearRetry(e);
|
|
575
|
+
if (isOpen(name) && e.connected) return status(name);
|
|
576
|
+
await reconnectTick(e);
|
|
577
|
+
return status(name);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 测试用:隧道子进程 pid(模拟「隧道被外力打断」需要它)。 */
|
|
581
|
+
export function _childPid(name) {
|
|
582
|
+
return entry(name)?.child?.pid ?? null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 测试用:丢弃全部内存态(不杀子进程,调用方自理)。 */
|
|
586
|
+
export function _reset() {
|
|
587
|
+
for (const e of entries.values()) clearRetry(e);
|
|
588
|
+
entries.clear();
|
|
589
|
+
}
|