@zhangfengshun/dsh-remote-ssh 2.1.7 → 2.2.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/CHANGELOG.md +13 -0
- package/lib/index.js +125 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
|
|
4
4
|
|
|
5
|
+
## [2.2.0] — 远程命令连接复用(实测 ≈15× 提速)+ 协议/安全加固
|
|
6
|
+
### 性能
|
|
7
|
+
- **`remote_ssh_exec` 走持久会话池**:不再每次调用做完整 SSH 握手。真实 HPC(ssh.cn-zhongwei-1.paracloud.com,2222 端口)实测:一次性连接单命令平均 **1292ms**,连接复用后单命令平均 **84.3ms**,**约 15.3× 提速**;`stdin` 参数经 heredoc 走同一条复用通道。
|
|
8
|
+
- **stderr/stdout 分离语义保留**:stderr 重定向到远端临时文件、退出码哨兵后 cat 回传并以第二个哨兵收尾(双哨兵协议)。
|
|
9
|
+
- **读路径保持轻量**:`ls`/`cat`/`grep`/`glob` 继续走 `2>&1` 合并协议,热路径避开子 shell 与临时文件开销。
|
|
10
|
+
- **大文件读取先 stat**:超过 4MB 只读前 4MB(`head -c` 截断并标记 truncated),不再让持久会话缓冲无限增长。
|
|
11
|
+
### 安全 / 健壮性
|
|
12
|
+
- **会话输出硬上限 8MB**:单条命令哨兵到达前缓冲超限即重置会话并给出明确报错,防止远端海量输出撑爆内存。
|
|
13
|
+
- **密钥认证非交互连接加 `-o BatchMode=yes -o PreferredAuthentications=publickey`**:带口令的私钥/意外交互立即失败并返回可读提示(不再挂到超时),同时缩短建连时间;交互式终端(`ssh -tt`)不受影响。
|
|
14
|
+
- **分离协议用双层子 shell**:用户命令中的 `exit`/`cd` 不再弄挂共享会话(内层子 shell 隔离,实测远端 `exit 7` 后会话存活且退出码正确传回)。
|
|
15
|
+
- **写入上限 4MB**:超限直接拒绝并给出清晰报错;写入失败现在能拿回远端 stderr(此前错误信息丢失)。
|
|
16
|
+
- 配合 2.1.7 纳入的修复(`--include` 转义、stderr 排空、删除配置级联清理、keyPath `~` 展开、会话池 key 含认证信息),完成一轮兼容性/安全性全面检查。
|
|
17
|
+
|
|
5
18
|
## [2.1.7] — 兼容 DSH Desktop ≥ 2.0.4:polyfill __DSH_MODULES__ + 可靠性/安全修复
|
|
6
19
|
### 修复
|
|
7
20
|
- **__DSH_MODULES__ polyfill**:better-sidebar 0.15+ 的懒加载 chunk(编辑器等)依赖 `globalThis.__DSH_MODULES__`,而 DSH Desktop ≥ 2.0.4 的 shell 不再挂载该全局,导致侧边栏面板打不开("chunk ... client module system unavailable")。Client 半边启动时用内核 `modules` 服务补挂该全局(better-sidebar 已注入则保持原值),并带定期兜底重试。
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,8 @@ const Config = z.object({});
|
|
|
25
25
|
|
|
26
26
|
const NS = "dsh-remote-ssh";
|
|
27
27
|
const MAX_BYTES = 4 * 1024 * 1024;
|
|
28
|
+
/** 持久会话单条命令的 stdout 缓冲硬上限(防止大文件/海量输出撑爆内存)。 */
|
|
29
|
+
const SESSION_MAX_STDOUT = 8 * 1024 * 1024;
|
|
28
30
|
const API_BASE = "/remote-ssh/api/";
|
|
29
31
|
|
|
30
32
|
/** 连接配置 schema(存于 settings)。 */
|
|
@@ -161,8 +163,10 @@ async function listSshConfigHosts() {
|
|
|
161
163
|
* 避免每次操作都做完整 TCP 握手 + SSH 密钥交换 + 认证(超算/跳板机单次 2-10 秒)。
|
|
162
164
|
*
|
|
163
165
|
* 工作原理:向 bash 的 stdin 写入命令 + 哨兵标记 printf,从 stdout 读取直到哨兵出现,
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
+
* 哨兵后的数字即退出码。split(分离 stderr)模式下,stderr 重定向到远端临时文件,
|
|
167
|
+
* 退出码哨兵之后 cat 出来并用第二个哨兵收尾:保住 stdout/stderr 分离的同时,
|
|
168
|
+
* 让 remote_ssh_exec 这类调用也走连接复用(首调用建连后毫秒级返回)。
|
|
169
|
+
* 输出侧有硬上限 SESSION_MAX_STDOUT:哨兵出现前缓冲超限即重置会话,下次调用自动重建。
|
|
166
170
|
*/
|
|
167
171
|
class CommandSession {
|
|
168
172
|
constructor(subprocess, profile) {
|
|
@@ -226,34 +230,87 @@ class CommandSession {
|
|
|
226
230
|
|
|
227
231
|
_check() {
|
|
228
232
|
if (!this.current) return;
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
233
|
+
const item = this.current;
|
|
234
|
+
if (!item.exitParsed) {
|
|
235
|
+
// 找到主哨兵:解析退出码;split 模式还要等第二个哨兵(stderr 内容)
|
|
236
|
+
const idx = this.buf.indexOf(this.sentinel);
|
|
237
|
+
if (idx < 0) { this._enforceCap(); return; }
|
|
238
|
+
const before = this.buf.slice(0, idx);
|
|
239
|
+
const after = this.buf.slice(idx + this.sentinel.length);
|
|
240
|
+
const nl = after.indexOf("\n");
|
|
241
|
+
const codeStr = nl >= 0 ? after.slice(0, nl) : after;
|
|
242
|
+
this.buf = nl >= 0 ? after.slice(nl + 1) : "";
|
|
243
|
+
const exitCode = parseInt(codeStr, 10);
|
|
244
|
+
item.exitCode = isNaN(exitCode) ? -1 : exitCode;
|
|
245
|
+
item.stdout = before;
|
|
246
|
+
item.exitParsed = true;
|
|
247
|
+
if (!item.errSentinel) {
|
|
248
|
+
this.current = null;
|
|
249
|
+
item.resolve({ stdout: item.stdout, stderr: item.stderr, exitCode: item.exitCode });
|
|
250
|
+
this._next();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (item.errSentinel) {
|
|
255
|
+
// split 模式第二段:等 stderr 哨兵(可能已在缓冲里)
|
|
256
|
+
const idx = this.buf.indexOf(item.errSentinel);
|
|
257
|
+
if (idx < 0) { this._enforceCap(); return; }
|
|
258
|
+
const errText = this.buf.slice(0, idx);
|
|
259
|
+
const after = this.buf.slice(idx + item.errSentinel.length);
|
|
260
|
+
const nl = after.indexOf("\n");
|
|
261
|
+
this.buf = nl >= 0 ? after.slice(nl + 1) : "";
|
|
262
|
+
item.stderr = errText.replace(/\n$/, "");
|
|
263
|
+
this.current = null;
|
|
264
|
+
item.resolve({ stdout: item.stdout, stderr: item.stderr, exitCode: item.exitCode });
|
|
265
|
+
this._next();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 单条命令输出超过硬上限:立即重置会话(下次调用自动重建),拒绝挂起/撑爆内存。 */
|
|
270
|
+
_enforceCap() {
|
|
271
|
+
if (!this.current || this.buf.length < SESSION_MAX_STDOUT) return;
|
|
272
|
+
const item = this.current;
|
|
239
273
|
this.current = null;
|
|
240
|
-
|
|
241
|
-
this.
|
|
274
|
+
item.reject(new Error("单条命令输出超过 " + SESSION_MAX_STDOUT + " 字节上限,会话已重置(若确实需要更大输出请拆分命令)"));
|
|
275
|
+
this.alive = false;
|
|
276
|
+
if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
|
|
277
|
+
while (this.queue.length) this.queue.shift().reject(new Error("ssh 会话已重置"));
|
|
242
278
|
}
|
|
243
279
|
|
|
244
280
|
_next() {
|
|
245
281
|
if (this.current || !this.alive || this.queue.length === 0) return;
|
|
246
282
|
const item = this.queue.shift();
|
|
247
|
-
|
|
283
|
+
item.exitParsed = false;
|
|
284
|
+
item.stdout = "";
|
|
285
|
+
item.stderr = "";
|
|
286
|
+
item.exitCode = -1;
|
|
287
|
+
item.errSentinel = item.split
|
|
288
|
+
? "DSHERR" + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 8)
|
|
289
|
+
: null;
|
|
290
|
+
this.current = item;
|
|
248
291
|
try {
|
|
292
|
+
const errFile = "${TMPDIR:-/tmp}/.dsh-err-" + Math.random().toString(36).slice(2, 10);
|
|
293
|
+
const exitMark = "printf '\\n" + this.sentinel + "%s\\n' $?";
|
|
294
|
+
const errTail = "cat " + errFile + " 2>/dev/null; rm -f " + errFile + "; printf '\\n" + item.errSentinel + "ok\\n'";
|
|
249
295
|
let toWrite;
|
|
250
|
-
if (item.
|
|
251
|
-
//
|
|
296
|
+
if (item.split) {
|
|
297
|
+
// 分离 stderr 用双层子 shell:
|
|
298
|
+
// 外层 ( ... ) 保证 printf 链自身不会受用户命令返回值影响;
|
|
299
|
+
// 内层 ( { cmd ; } ) 隔离用户命令里的 exit/cd,绝不弄挂共享 bash 会话
|
|
300
|
+
// (exit 只会退出内层子 shell,后面照常打印哨兵与退出码)。
|
|
301
|
+
if (item.stdinData !== undefined && item.stdinData !== null) {
|
|
302
|
+
const delim = "DSHW" + Math.random().toString(36).slice(2, 14);
|
|
303
|
+
toWrite = "( ( " + item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n) 2>" + errFile + "; " + exitMark + "; " + errTail + " )\n";
|
|
304
|
+
} else {
|
|
305
|
+
toWrite = "( ( " + item.cmd + " ; ) 2>" + errFile + "; " + exitMark + "; " + errTail + " )\n";
|
|
306
|
+
}
|
|
307
|
+
} else if (item.stdinData !== undefined && item.stdinData !== null) {
|
|
308
|
+
// 快速路径写操作:heredoc 传内容,2>&1 合并(成功时 stderr 为空,不影响解析)
|
|
252
309
|
const delim = "DSHW" + Math.random().toString(36).slice(2, 14);
|
|
253
|
-
toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\
|
|
310
|
+
toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n" + exitMark + "\n";
|
|
254
311
|
} else {
|
|
255
|
-
//
|
|
256
|
-
toWrite = "{ " + item.cmd + " ; } 2>&1\
|
|
312
|
+
// 读/列举/搜索快速路径:2>&1 合并 stderr(成功时 stderr 为空)
|
|
313
|
+
toWrite = "{ " + item.cmd + " ; } 2>&1\n" + exitMark + "\n";
|
|
257
314
|
}
|
|
258
315
|
this.handle.stdin.write(toWrite);
|
|
259
316
|
} catch (e) {
|
|
@@ -263,11 +320,11 @@ class CommandSession {
|
|
|
263
320
|
}
|
|
264
321
|
}
|
|
265
322
|
|
|
266
|
-
async exec(cmd, stdinData) {
|
|
323
|
+
async exec(cmd, stdinData, split) {
|
|
267
324
|
await this.connect();
|
|
268
325
|
this.lastUsed = Date.now();
|
|
269
326
|
return new Promise((resolve, reject) => {
|
|
270
|
-
this.queue.push({ cmd, stdinData, resolve, reject });
|
|
327
|
+
this.queue.push({ cmd, stdinData, split: !!split, resolve, reject });
|
|
271
328
|
this._next();
|
|
272
329
|
});
|
|
273
330
|
}
|
|
@@ -293,6 +350,13 @@ function sshArgv(p, remoteCmd, tty) {
|
|
|
293
350
|
opts.push("-o", "ClearAllForwardings=yes");
|
|
294
351
|
if (p.proxyJump) opts.push("-o", "ProxyJump=" + String(p.proxyJump));
|
|
295
352
|
if (p.authMethod === "key" && p.keyPath) opts.push("-i", expandSshPath(p.keyPath));
|
|
353
|
+
// 密钥认证的非交互连接(会话池 / 工具 / 同步)要求一次性无提示完成:
|
|
354
|
+
// BatchMode 禁用 passphrase/确认交互(有口令的密钥立即报错而不是挂到超时),
|
|
355
|
+
// PreferredAuthentications=publickey 跳过无谓的认证方法协商,缩短建连时间。
|
|
356
|
+
if (!tty && p.authMethod === "key") {
|
|
357
|
+
opts.push("-o", "BatchMode=yes");
|
|
358
|
+
opts.push("-o", "PreferredAuthentications=publickey");
|
|
359
|
+
}
|
|
296
360
|
const target = String(p.user || "") + "@" + String(p.host || "");
|
|
297
361
|
const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
|
|
298
362
|
const argv = head.concat(opts, [target]);
|
|
@@ -366,8 +430,23 @@ async function remoteListDir(runner, p, path) {
|
|
|
366
430
|
|
|
367
431
|
async function remoteReadFile(runner, p, path) {
|
|
368
432
|
if (!path) return { ok: false, error: "path 为必填项" };
|
|
369
|
-
|
|
370
|
-
|
|
433
|
+
// 先 stat 拿大小:超过 MAX_BYTES 的大文件只读前 4MB(head -c 截断),
|
|
434
|
+
// 避免把持久会话的 stdout 缓冲撑爆(会话层另有 8MB 硬上限兜底)。
|
|
435
|
+
// 读路径走 2>&1 合并协议(热路径,省掉一次子 shell 与 stderr 重定向)。
|
|
436
|
+
const st = await runner(p, "stat -c%s " + shellQuotePath(path));
|
|
437
|
+
let size = null;
|
|
438
|
+
if (st.ok) {
|
|
439
|
+
const n = parseInt(String(st.stdout || "").trim(), 10);
|
|
440
|
+
if (!isNaN(n)) size = n;
|
|
441
|
+
}
|
|
442
|
+
let cmd = "base64 -w0 " + shellQuotePath(path);
|
|
443
|
+
let truncated = false;
|
|
444
|
+
if (size !== null && size > MAX_BYTES) {
|
|
445
|
+
cmd = "head -c " + MAX_BYTES + " " + shellQuotePath(path) + " | base64 -w0";
|
|
446
|
+
truncated = true;
|
|
447
|
+
}
|
|
448
|
+
const r = await runner(p, cmd, undefined, MAX_BYTES);
|
|
449
|
+
if (!r.ok) return { ok: false, error: String(r.error || r.stderr || "").trim() || "读取失败" };
|
|
371
450
|
let text = "";
|
|
372
451
|
let binary = false;
|
|
373
452
|
try {
|
|
@@ -378,13 +457,16 @@ async function remoteReadFile(runner, p, path) {
|
|
|
378
457
|
} catch (e) {
|
|
379
458
|
return { ok: false, error: "解码失败: " + String(e) };
|
|
380
459
|
}
|
|
381
|
-
return { ok: true, path: path, content: text, binary: binary, truncated: r.truncated };
|
|
460
|
+
return { ok: true, path: path, content: text, binary: binary, truncated: truncated || r.truncated };
|
|
382
461
|
}
|
|
383
462
|
|
|
384
463
|
async function remoteWriteFile(runner, p, path, content) {
|
|
385
464
|
if (!path) return { ok: false, error: "path 为必填项" };
|
|
386
465
|
const c = content !== undefined ? String(content) : "";
|
|
387
|
-
|
|
466
|
+
if (c.length > MAX_BYTES) {
|
|
467
|
+
return { ok: false, exitCode: -1, stdout: "", stderr: "", error: "写入内容超过 " + MAX_BYTES + " 字节上限", truncated: false };
|
|
468
|
+
}
|
|
469
|
+
return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES, true);
|
|
388
470
|
}
|
|
389
471
|
|
|
390
472
|
/**
|
|
@@ -685,8 +767,9 @@ function apply(ctx, config) {
|
|
|
685
767
|
return t.trim().slice(0, 500);
|
|
686
768
|
}
|
|
687
769
|
|
|
688
|
-
/** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。
|
|
689
|
-
|
|
770
|
+
/** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。
|
|
771
|
+
* split=true 时 stderr 与 stdout 分离返回(命令走双哨兵协议)。 */
|
|
772
|
+
async function runPooled(p, cmd, stdinData, maxBytes, split) {
|
|
690
773
|
const dropSession = () => {
|
|
691
774
|
const key = profileKey(p);
|
|
692
775
|
const old = sessions.get(key);
|
|
@@ -694,11 +777,16 @@ function apply(ctx, config) {
|
|
|
694
777
|
};
|
|
695
778
|
try {
|
|
696
779
|
const s = getSession(p);
|
|
697
|
-
const r = await s.exec(cmd, stdinData);
|
|
698
|
-
|
|
780
|
+
const r = await s.exec(cmd, stdinData, split);
|
|
781
|
+
const stderr = split ? String(r.stderr || "") : "";
|
|
782
|
+
if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: stderr, error: "", truncated: false };
|
|
699
783
|
// exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
|
|
700
784
|
if (r.exitCode === 255) dropSession();
|
|
701
|
-
|
|
785
|
+
const errSource = String(stderr || r.stdout || "");
|
|
786
|
+
const error = r.exitCode === 255
|
|
787
|
+
? sshErrorHint(errSource)
|
|
788
|
+
: (errSource.trim().slice(0, 500) || ("ssh 退出码 " + r.exitCode));
|
|
789
|
+
return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: stderr, error: error, truncated: false };
|
|
702
790
|
} catch (e) {
|
|
703
791
|
// 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
|
|
704
792
|
dropSession();
|
|
@@ -934,7 +1022,8 @@ function apply(ctx, config) {
|
|
|
934
1022
|
const p = resolveProfile(args);
|
|
935
1023
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
936
1024
|
if (!args || !args.command) return { ok: false, error: "command 为必填项" };
|
|
937
|
-
|
|
1025
|
+
// 走持久会话池(stderr 分离):首调用建立连接后,后续调用毫秒级返回。
|
|
1026
|
+
return await runPooled(p, args.command, args.stdin, undefined, true);
|
|
938
1027
|
},
|
|
939
1028
|
listDir: async (args) => {
|
|
940
1029
|
const p = resolveProfile(args);
|
|
@@ -1130,7 +1219,10 @@ function apply(ctx, config) {
|
|
|
1130
1219
|
if (!args.command) return normExec({ ok: false, error: "command 为必填项" });
|
|
1131
1220
|
let cmd = String(args.command);
|
|
1132
1221
|
if (tc.remotePath) cmd = "cd " + shellQuotePath(tc.remotePath) + " 2>/dev/null; " + cmd;
|
|
1133
|
-
|
|
1222
|
+
// 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
|
|
1223
|
+
// 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
|
|
1224
|
+
// 会话异常时 runPooled 自动重建并降级一次性连接。
|
|
1225
|
+
return normExec(await runPooled(tc.profile, cmd, args.stdin, undefined, true));
|
|
1134
1226
|
}
|
|
1135
1227
|
});
|
|
1136
1228
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|