@bingzi-233/ssh-mcp 1.4.0 → 1.5.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/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { closeSession, listSessions, openSession, runCommand, } from "./ssh.js";
7
7
  import { cancelTransfer, getTransfer, listTransfers, startTransfer, } from "./transfer.js";
8
8
  import { startForward, listForwards, closeForward, } from "./forward.js";
9
9
  import { listDirectory, statPath, removePath, makeDir, formatLsLong, formatLsShort, } from "./sftp-ops.js";
10
+ import { getHealth, getCertInfo, copyBetween, diffServers, execScript, snapshot, startTailFollow, stopTailFollow, getTailFollow, listTailFollows, httpRequest, getRemoteEnv, startWatch, stopWatch, getWatch, listWatches, } from "./ops.js";
10
11
  const DEFAULT_TIMEOUT_MS = 60_000;
11
12
  function humanBytes(n) {
12
13
  if (n < 1024)
@@ -58,7 +59,7 @@ function formatTransfer(t) {
58
59
  // MCP server setup (--mcp mode)
59
60
  // ---------------------------------------------------------------------------
60
61
  function createMcpServer() {
61
- const server = new McpServer({ name: "ssh-mcp", version: "1.4.0" });
62
+ const server = new McpServer({ name: "ssh-mcp", version: "1.5.1" });
62
63
  server.registerTool("list_servers", {
63
64
  title: "列出可用的 SSH 服务器",
64
65
  description: "列出所有已配置的远程服务器及其 name、描述、地址和登录用户。" +
@@ -480,6 +481,340 @@ function createMcpServer() {
480
481
  return { content: [{ type: "text", text: `创建目录失败:${e.message}` }], isError: true };
481
482
  }
482
483
  });
484
+ // ---- 健康检查 ----
485
+ server.registerTool("health_check", {
486
+ title: "远程服务器健康检查",
487
+ description: "一键收集远程服务器健康报告:主机名、操作系统、运行时长、负载、内存、磁盘、CPU 核心数。",
488
+ inputSchema: {
489
+ server: z.string().describe("目标服务器 name"),
490
+ },
491
+ annotations: { readOnlyHint: true, openWorldHint: true },
492
+ }, async ({ server: serverName }) => {
493
+ try {
494
+ const servers = loadServers();
495
+ const cfg = servers.get(serverName);
496
+ if (!cfg)
497
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
498
+ const h = await getHealth(cfg);
499
+ const lines = [
500
+ `=== ${h.server} (${h.hostname}) ===`,
501
+ `操作系统: ${h.os}`,
502
+ `运行时长: ${h.uptime}`,
503
+ `平均负载: ${h.load}`,
504
+ `CPU 核心: ${h.cpuCores}`,
505
+ `内存:\n${h.memory}`,
506
+ `磁盘:\n${h.disk}`,
507
+ ];
508
+ return { content: [{ type: "text", text: lines.join("\n") }] };
509
+ }
510
+ catch (e) {
511
+ return { content: [{ type: "text", text: `健康检查失败:${e.message}` }], isError: true };
512
+ }
513
+ });
514
+ // ---- SSL 证书 ----
515
+ server.registerTool("cert_info", {
516
+ title: "查看 SSL/TLS 证书信息",
517
+ description: "通过远程服务器上的 openssl 拉取目标主机的 SSL 证书,解析主题、签发者、SAN、有效期、指纹和剩余天数。",
518
+ inputSchema: {
519
+ server: z.string().describe("执行 openssl 的 SSH 服务器 name"),
520
+ host: z.string().describe("要检查证书的目标主机名或 IP"),
521
+ port: z.number().int().positive().optional().describe("目标端口,默认 443"),
522
+ },
523
+ annotations: { readOnlyHint: true, openWorldHint: true },
524
+ }, async ({ server: serverName, host, port }) => {
525
+ try {
526
+ const servers = loadServers();
527
+ const cfg = servers.get(serverName);
528
+ if (!cfg)
529
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
530
+ const c = await getCertInfo(cfg, host, port ?? 443);
531
+ const lines = [
532
+ `主题: ${c.subject}`,
533
+ `签发者: ${c.issuer}`,
534
+ `有效期: ${c.notBefore} → ${c.notAfter}`,
535
+ `剩余天数: ${c.remainingDays}`,
536
+ `指纹: ${c.fingerprint}`,
537
+ `SAN: ${c.sans.length ? c.sans.join(", ") : "(无)"}`,
538
+ ];
539
+ return { content: [{ type: "text", text: lines.join("\n") }] };
540
+ }
541
+ catch (e) {
542
+ return { content: [{ type: "text", text: `证书检查失败:${e.message}` }], isError: true };
543
+ }
544
+ });
545
+ // ---- 服务器间直传 ----
546
+ server.registerTool("copy_between", {
547
+ title: "服务器间直接传输文件",
548
+ description: "在两台远程服务器之间直接 SFTP 传输文件,数据不经过本机中转。" +
549
+ "从 source_server 的 source_path 读取,写入 dest_server 的 dest_path。",
550
+ inputSchema: {
551
+ source_server: z.string().describe("源服务器 name"),
552
+ dest_server: z.string().describe("目标服务器 name"),
553
+ source_path: z.string().describe("源文件路径"),
554
+ dest_path: z.string().describe("目标文件路径"),
555
+ },
556
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
557
+ }, async ({ source_server, dest_server, source_path, dest_path }) => {
558
+ try {
559
+ const servers = loadServers();
560
+ const src = servers.get(source_server);
561
+ const dest = servers.get(dest_server);
562
+ if (!src)
563
+ return { content: [{ type: "text", text: `未找到源服务器 "${source_server}"` }], isError: true };
564
+ if (!dest)
565
+ return { content: [{ type: "text", text: `未找到目标服务器 "${dest_server}"` }], isError: true };
566
+ const r = await copyBetween(src, dest, source_path, dest_path);
567
+ return { content: [{ type: "text", text: `已复制 ${humanBytes(r.size)}:${r.sourceServer}:${r.sourcePath} → ${r.destServer}:${r.destPath}(耗时 ${(r.elapsedMs / 1000).toFixed(1)}s)` }] };
568
+ }
569
+ catch (e) {
570
+ return { content: [{ type: "text", text: `服务器间复制失败:${e.message}` }], isError: true };
571
+ }
572
+ });
573
+ // ---- 服务器文件对比 ----
574
+ server.registerTool("diff_servers", {
575
+ title: "对比两台服务器上同一文件的差异",
576
+ description: "对比两台服务器上同一路径的文件内容,返回 unified diff 格式的差异。" +
577
+ "用于排查配置漂移、确认多台服务器配置一致性。",
578
+ inputSchema: {
579
+ server_a: z.string().describe("第一台服务器 name"),
580
+ server_b: z.string().describe("第二台服务器 name"),
581
+ path: z.string().describe("要对比的文件路径"),
582
+ },
583
+ annotations: { readOnlyHint: true, openWorldHint: true },
584
+ }, async ({ server_a, server_b, path }) => {
585
+ try {
586
+ const servers = loadServers();
587
+ const a = servers.get(server_a);
588
+ const b = servers.get(server_b);
589
+ if (!a)
590
+ return { content: [{ type: "text", text: `未找到服务器 "${server_a}"` }], isError: true };
591
+ if (!b)
592
+ return { content: [{ type: "text", text: `未找到服务器 "${server_b}"` }], isError: true };
593
+ const d = await diffServers(a, b, path);
594
+ if (d.identical)
595
+ return { content: [{ type: "text", text: `${server_a} 和 ${server_b} 上 ${path} 内容一致。` }] };
596
+ return { content: [{ type: "text", text: `--- ${server_a}:${path}\n+++ ${server_b}:${path}\n${d.diff}` }] };
597
+ }
598
+ catch (e) {
599
+ return { content: [{ type: "text", text: `对比失败:${e.message}` }], isError: true };
600
+ }
601
+ });
602
+ // ---- 脚本执行 ----
603
+ server.registerTool("exec_script", {
604
+ title: "上传脚本到远程执行并清理",
605
+ description: "将本机脚本文件上传到远程服务器,设置执行权限,运行后自动删除临时文件。" +
606
+ "一站式操作,无需手动清理。",
607
+ inputSchema: {
608
+ server: z.string().describe("目标服务器 name"),
609
+ local_script: z.string().describe("本机脚本文件的绝对路径"),
610
+ remote_path: z.string().optional().describe("远程暂存路径,默认 /tmp/ssh-mcp-script.sh"),
611
+ timeout_ms: z.number().int().positive().optional().describe("执行超时毫秒数,默认 120000"),
612
+ },
613
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
614
+ }, async ({ server: serverName, local_script, remote_path, timeout_ms }) => {
615
+ try {
616
+ const servers = loadServers();
617
+ const cfg = servers.get(serverName);
618
+ if (!cfg)
619
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
620
+ const r = await execScript(cfg, local_script, remote_path ?? "/tmp/ssh-mcp-script.sh", timeout_ms ?? 120_000);
621
+ return { content: [{ type: "text", text: `退出码: ${r.exitCode}\n${r.stdout}\n${r.stderr ? "--- stderr ---\n" + r.stderr : ""}`.trim() }] };
622
+ }
623
+ catch (e) {
624
+ return { content: [{ type: "text", text: `脚本执行失败:${e.message}` }], isError: true };
625
+ }
626
+ });
627
+ // ---- 快照 ----
628
+ server.registerTool("snapshot", {
629
+ title: "远程目录快照打包下载",
630
+ description: "将远程服务器上的目录通过 tar.gz 打包后流式下载到本机。" +
631
+ "支持 --exclude 排除模式。适用于备份、迁移场景。",
632
+ inputSchema: {
633
+ server: z.string().describe("目标服务器 name"),
634
+ remote_dir: z.string().describe("远程目录路径"),
635
+ local_file: z.string().describe("本机输出文件路径(.tar.gz)"),
636
+ excludes: z.array(z.string()).optional().describe("排除模式列表,如 ['*.log', 'node_modules']"),
637
+ },
638
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
639
+ }, async ({ server: serverName, remote_dir, local_file, excludes }) => {
640
+ try {
641
+ const servers = loadServers();
642
+ const cfg = servers.get(serverName);
643
+ if (!cfg)
644
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
645
+ const r = await snapshot(cfg, remote_dir, local_file, excludes ?? []);
646
+ return { content: [{ type: "text", text: `快照完成\n 服务器: ${r.server}:${r.remotePath}\n 本地: ${r.localFile}\n 大小: ${humanBytes(r.fileSize)}\n 耗时: ${(r.elapsedMs / 1000).toFixed(1)}s` }] };
647
+ }
648
+ catch (e) {
649
+ return { content: [{ type: "text", text: `快照失败:${e.message}` }], isError: true };
650
+ }
651
+ });
652
+ // ---- tail-f ----
653
+ server.registerTool("start_tail", {
654
+ title: "持续追踪远程文件(tail -f)",
655
+ description: "以 SFTP 轮询方式持续追踪远程文件的增长内容。返回 tail id," +
656
+ "用 get_tail 抓取已收集的内容,stop_tail 停止追踪。",
657
+ inputSchema: {
658
+ server: z.string().describe("目标服务器 name"),
659
+ path: z.string().describe("要追踪的远程文件路径"),
660
+ interval_ms: z.number().int().positive().optional().describe("轮询间隔毫秒数,默认 2000"),
661
+ },
662
+ annotations: { readOnlyHint: true, openWorldHint: true },
663
+ }, async ({ server: serverName, path, interval_ms }) => {
664
+ try {
665
+ const servers = loadServers();
666
+ const cfg = servers.get(serverName);
667
+ if (!cfg)
668
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
669
+ const chunks = [];
670
+ const t = await startTailFollow(cfg, path, interval_ms ?? 2000, (_id, chunk) => chunks.push(chunk));
671
+ return { content: [{ type: "text", text: `tail 已启动 [${t.id}]\n 服务器: ${t.server}\n 文件: ${t.path}\n 用 get_tail({ id: "${t.id}" }) 获取内容,stop_tail({ id: "${t.id}" }) 停止。` }] };
672
+ }
673
+ catch (e) {
674
+ return { content: [{ type: "text", text: `启动 tail 失败:${e.message}` }], isError: true };
675
+ }
676
+ });
677
+ server.registerTool("get_tail", {
678
+ title: "查看 tail 追踪状态",
679
+ description: "查看指定 tail 追踪任务的状态、已收集字节数等信息。",
680
+ inputSchema: { id: z.string().optional().describe("tail id;不填则列出所有") },
681
+ annotations: { readOnlyHint: true, openWorldHint: false },
682
+ }, async ({ id }) => {
683
+ if (id) {
684
+ const t = getTailFollow(id);
685
+ if (!t)
686
+ return { content: [{ type: "text", text: `未找到 tail: ${id}` }], isError: true };
687
+ return { content: [{ type: "text", text: `[${t.id}] ${t.server}:${t.path} — ${t.state}(${humanBytes(t.seenBytes)} 已跟踪)` }] };
688
+ }
689
+ const all = listTailFollows();
690
+ const text = all.length ? all.map((t) => `[${t.id}] ${t.server}:${t.path} — ${t.state}(${humanBytes(t.seenBytes)})`).join("\n") : "当前没有 tail 任务。";
691
+ return { content: [{ type: "text", text }] };
692
+ });
693
+ server.registerTool("stop_tail", {
694
+ title: "停止 tail 追踪",
695
+ description: "停止一个 tail 追踪任务。",
696
+ inputSchema: { id: z.string().describe("tail id") },
697
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
698
+ }, async ({ id }) => {
699
+ if (!stopTailFollow(id))
700
+ return { content: [{ type: "text", text: `未找到 tail: ${id}` }], isError: true };
701
+ return { content: [{ type: "text", text: `tail ${id} 已停止。` }] };
702
+ });
703
+ // ---- watch ----
704
+ server.registerTool("start_watch", {
705
+ title: "定时重复执行命令并高亮差异",
706
+ description: "在远程服务器上按指定间隔重复执行命令。每次执行结果与上次对比," +
707
+ "自动高亮变化行。返回 watch id,用 stop_watch 停止。",
708
+ inputSchema: {
709
+ server: z.string().describe("目标服务器 name"),
710
+ command: z.string().describe("要重复执行的命令"),
711
+ interval_ms: z.number().int().positive().describe("执行间隔毫秒数"),
712
+ timeout_ms: z.number().int().positive().optional().describe("每次命令执行超时毫秒数,默认 10000"),
713
+ },
714
+ annotations: { readOnlyHint: true, openWorldHint: true },
715
+ }, async ({ server: serverName, command, interval_ms, timeout_ms }) => {
716
+ try {
717
+ const servers = loadServers();
718
+ const cfg = servers.get(serverName);
719
+ if (!cfg)
720
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
721
+ const iterations = [];
722
+ const wh = startWatch(cfg, command, interval_ms, (_id, iter) => iterations.push(iter), timeout_ms ?? 10_000);
723
+ return { content: [{ type: "text", text: `watch 已启动 [${wh.id}]\n 服务器: ${wh.server}\n 命令: ${wh.command}\n 间隔: ${wh.intervalMs}ms\n 用 get_watch({ id: "${wh.id}" }) 查看或 stop_watch({ id: "${wh.id}" }) 停止。` }] };
724
+ }
725
+ catch (e) {
726
+ return { content: [{ type: "text", text: `启动 watch 失败:${e.message}` }], isError: true };
727
+ }
728
+ });
729
+ server.registerTool("get_watch", {
730
+ title: "查看 watch 状态",
731
+ description: "查看指定 watch 任务的状态。",
732
+ inputSchema: { id: z.string().optional().describe("watch id;不填则列出所有") },
733
+ annotations: { readOnlyHint: true, openWorldHint: false },
734
+ }, async ({ id }) => {
735
+ if (id) {
736
+ const w = getWatch(id);
737
+ if (!w)
738
+ return { content: [{ type: "text", text: `未找到 watch: ${id}` }], isError: true };
739
+ return { content: [{ type: "text", text: `[${w.id}] ${w.server}: ${w.command}(每 ${w.intervalMs}ms)— ${w.state}` }] };
740
+ }
741
+ const all = listWatches();
742
+ const text = all.length ? all.map((w) => `[${w.id}] ${w.server}: ${w.command}(每 ${w.intervalMs}ms)— ${w.state}`).join("\n") : "当前没有 watch 任务。";
743
+ return { content: [{ type: "text", text }] };
744
+ });
745
+ server.registerTool("stop_watch", {
746
+ title: "停止 watch",
747
+ description: "停止一个定时 watch 任务。",
748
+ inputSchema: { id: z.string().describe("watch id") },
749
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
750
+ }, async ({ id }) => {
751
+ if (!stopWatch(id))
752
+ return { content: [{ type: "text", text: `未找到 watch: ${id}` }], isError: true };
753
+ return { content: [{ type: "text", text: `watch ${id} 已停止。` }] };
754
+ });
755
+ // ---- HTTP 请求 ----
756
+ server.registerTool("http_request", {
757
+ title: "从远程服务器发起 HTTP 请求",
758
+ description: "在远程服务器上通过 curl 执行 HTTP 请求,以远程服务器的网络视角探测目标。" +
759
+ "适用于内网接口调试、从服务器视角访问受限端点。",
760
+ inputSchema: {
761
+ server: z.string().describe("目标服务器 name"),
762
+ url: z.string().describe("请求 URL"),
763
+ method: z.string().optional().describe("HTTP 方法,默认 GET"),
764
+ headers: z.record(z.string(), z.string()).optional().describe("请求头键值对"),
765
+ body: z.string().optional().describe("请求体"),
766
+ timeout_ms: z.number().int().positive().optional().describe("超时毫秒数,默认 30000"),
767
+ },
768
+ annotations: { readOnlyHint: true, openWorldHint: true },
769
+ }, async ({ server: serverName, url, method, headers, body, timeout_ms }) => {
770
+ try {
771
+ const servers = loadServers();
772
+ const cfg = servers.get(serverName);
773
+ if (!cfg)
774
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
775
+ const r = await httpRequest(cfg, url, method ?? "GET", headers ?? {}, body, timeout_ms ?? 30_000);
776
+ return { content: [{ type: "text", text: `HTTP ${r.httpCode}(耗时 ${r.duration})\n${r.body}` }] };
777
+ }
778
+ catch (e) {
779
+ return { content: [{ type: "text", text: `HTTP 请求失败:${e.message}` }], isError: true };
780
+ }
781
+ });
782
+ // ---- 环境信息 ----
783
+ server.registerTool("remote_env", {
784
+ title: "收集远程服务器环境信息",
785
+ description: "收集远程服务器的环境变量、登录用户、网络端口监听、进程信息。" +
786
+ "可选传入 process_name 搜索特定进程。",
787
+ inputSchema: {
788
+ server: z.string().describe("目标服务器 name"),
789
+ process_name: z.string().optional().describe("搜索的进程名(可选)"),
790
+ },
791
+ annotations: { readOnlyHint: true, openWorldHint: true },
792
+ }, async ({ server: serverName, process_name }) => {
793
+ try {
794
+ const servers = loadServers();
795
+ const cfg = servers.get(serverName);
796
+ if (!cfg)
797
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
798
+ const e = await getRemoteEnv(cfg, process_name);
799
+ const lines = [`=== ${e.server} 环境信息 ===`];
800
+ if (e.procInfo)
801
+ lines.push(`进程: PID=${e.procInfo.pid} PPID=${e.procInfo.ppid} CMD=${e.procInfo.cmdline}`);
802
+ lines.push(`登录用户:\n${e.users}`);
803
+ lines.push(`网络监听:\n${e.network}`);
804
+ if (e.openFiles)
805
+ lines.push(`文件句柄(尾部):\n${e.openFiles}`);
806
+ const envKeys = Object.keys(e.envVars);
807
+ if (envKeys.length) {
808
+ lines.push(`环境变量 (${envKeys.length} 个):`);
809
+ for (const [k, v] of Object.entries(e.envVars))
810
+ lines.push(` ${k}=${v}`);
811
+ }
812
+ return { content: [{ type: "text", text: lines.join("\n") }] };
813
+ }
814
+ catch (e) {
815
+ return { content: [{ type: "text", text: `收集环境信息失败:${e.message}` }], isError: true };
816
+ }
817
+ });
483
818
  return server;
484
819
  }
485
820
  function parseArgs(raw) {
@@ -548,7 +883,7 @@ function die(msg) {
548
883
  process.exit(1);
549
884
  }
550
885
  function showHelp() {
551
- process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.4.0
886
+ process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.5.1
552
887
 
553
888
  用法: ssh-mcp <子命令> [选项]
554
889
 
@@ -570,6 +905,20 @@ function showHelp() {
570
905
  stat 查看远程文件信息
571
906
  rm 删除远程文件或目录
572
907
  mkdir 创建远程目录
908
+ health 一键健康检查(OS/磁盘/内存/负载)
909
+ cert-info 查看 SSL/TLS 证书信息
910
+ copy-between 服务器间直传文件(不经本地)
911
+ diff-servers 对比两台服务器上同一文件差异
912
+ exec-script 上传脚本并执行(自动清理)
913
+ snapshot 远程目录 tar.gz 打包下载
914
+ tail-f 持续追踪远程文件(SFTP 轮询)
915
+ stop-tail 停止 tail 追踪
916
+ list-tails 列出所有 tail 追踪
917
+ watch 定时重复执行命令并高亮差异
918
+ stop-watch 停止 watch
919
+ list-watches 列出所有 watch 任务
920
+ curl 从远程服务器发起 HTTP 请求
921
+ env 收集远程服务器环境信息
573
922
 
574
923
  全局选项:
575
924
  --mcp 以 MCP stdio 服务模式运行(供 AI 客户端调用)
@@ -1204,6 +1553,468 @@ async function cmdMkdir(opts) {
1204
1553
  die(`创建目录失败:${e.message}`);
1205
1554
  }
1206
1555
  }
1556
+ // ---- 健康检查 CLI ----
1557
+ async function cmdHealth(opts) {
1558
+ if (optBool(opts, "help")) {
1559
+ process.stdout.write(`用法: ssh-mcp health --server <name>
1560
+
1561
+ 一键收集远程服务器健康信息(OS/磁盘/内存/负载/CPU)。
1562
+
1563
+ 示例:
1564
+ ssh-mcp health -s prod-web
1565
+ `);
1566
+ return;
1567
+ }
1568
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1569
+ if (!serverName)
1570
+ die("缺少 --server");
1571
+ try {
1572
+ const servers = loadServers();
1573
+ const cfg = servers.get(serverName);
1574
+ if (!cfg)
1575
+ die(`未找到服务器 "${serverName}"`);
1576
+ const h = await getHealth(cfg);
1577
+ process.stdout.write(`=== ${h.server} (${h.hostname}) ===
1578
+ 操作系统: ${h.os}
1579
+ 运行时长: ${h.uptime}
1580
+ 平均负载: ${h.load}
1581
+ CPU 核心: ${h.cpuCores}
1582
+ 内存:
1583
+ ${h.memory}
1584
+ 磁盘:
1585
+ ${h.disk}
1586
+ `);
1587
+ }
1588
+ catch (e) {
1589
+ die(`健康检查失败:${e.message}`);
1590
+ }
1591
+ }
1592
+ // ---- SSL 证书 CLI ----
1593
+ async function cmdCertInfo(opts) {
1594
+ if (optBool(opts, "help")) {
1595
+ process.stdout.write(`用法: ssh-mcp cert-info --server <name> --host <host> [--port <port>]
1596
+
1597
+ 通过远程服务器的 openssl 拉取目标 SSL 证书信息。
1598
+
1599
+ 选项:
1600
+ --server, -s <name> 执行 openssl 的 SSH 服务器
1601
+ --host <host> 目标主机(必需)
1602
+ --port <port> 目标端口(默认 443)
1603
+
1604
+ 示例:
1605
+ ssh-mcp cert-info -s prod-web --host example.com
1606
+ ssh-mcp cert-info -s prod-web --host 10.0.0.5 --port 8443
1607
+ `);
1608
+ return;
1609
+ }
1610
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1611
+ const host = optStr(opts, "host");
1612
+ if (!serverName)
1613
+ die("缺少 --server");
1614
+ if (!host)
1615
+ die("缺少 --host");
1616
+ try {
1617
+ const servers = loadServers();
1618
+ const cfg = servers.get(serverName);
1619
+ if (!cfg)
1620
+ die(`未找到服务器 "${serverName}"`);
1621
+ const c = await getCertInfo(cfg, host, parseInt(optStr(opts, "port") ?? "443", 10));
1622
+ process.stdout.write(`主题: ${c.subject}
1623
+ 签发者: ${c.issuer}
1624
+ 有效期: ${c.notBefore} → ${c.notAfter}
1625
+ 剩余天数: ${c.remainingDays}
1626
+ 指纹: ${c.fingerprint}
1627
+ SAN: ${c.sans.length ? c.sans.join(", ") : "(无)"}
1628
+ `);
1629
+ }
1630
+ catch (e) {
1631
+ die(`证书检查失败:${e.message}`);
1632
+ }
1633
+ }
1634
+ // ---- 服务器间直传 CLI ----
1635
+ async function cmdCopyBetween(opts) {
1636
+ if (optBool(opts, "help")) {
1637
+ process.stdout.write(`用法: ssh-mcp copy-between --src <name> --dst <name> --src-path <path> --dst-path <path>
1638
+
1639
+ 在两台远程服务器之间直接传输文件,数据不经本机。
1640
+
1641
+ 选项:
1642
+ --src <name> 源服务器 name(必需)
1643
+ --dst <name> 目标服务器 name(必需)
1644
+ --src-path <path> 源文件路径(必需)
1645
+ --dst-path <path> 目标文件路径(必需)
1646
+
1647
+ 示例:
1648
+ ssh-mcp copy-between --src web1 --dst web2 --src-path /opt/app/config.yml --dst-path /opt/app/config.yml
1649
+ `);
1650
+ return;
1651
+ }
1652
+ const src = optStr(opts, "src");
1653
+ const dst = optStr(opts, "dst");
1654
+ const srcPath = optStr(opts, "src-path");
1655
+ const dstPath = optStr(opts, "dst-path");
1656
+ if (!src)
1657
+ die("缺少 --src");
1658
+ if (!dst)
1659
+ die("缺少 --dst");
1660
+ if (!srcPath)
1661
+ die("缺少 --src-path");
1662
+ if (!dstPath)
1663
+ die("缺少 --dst-path");
1664
+ try {
1665
+ const servers = loadServers();
1666
+ const srvA = servers.get(src);
1667
+ const srvB = servers.get(dst);
1668
+ if (!srvA)
1669
+ die(`未找到源服务器 "${src}"`);
1670
+ if (!srvB)
1671
+ die(`未找到目标服务器 "${dst}"`);
1672
+ const r = await copyBetween(srvA, srvB, srcPath, dstPath);
1673
+ process.stdout.write(`已复制 ${humanBytes(r.size)}:${r.sourceServer}:${r.sourcePath} → ${r.destServer}:${r.destPath}(${(r.elapsedMs / 1000).toFixed(1)}s)\n`);
1674
+ }
1675
+ catch (e) {
1676
+ die(`复制失败:${e.message}`);
1677
+ }
1678
+ }
1679
+ // ---- 服务器文件对比 CLI ----
1680
+ async function cmdDiffServers(opts) {
1681
+ if (optBool(opts, "help")) {
1682
+ process.stdout.write(`用法: ssh-mcp diff-servers --server-a <name> --server-b <name> --path <path>
1683
+
1684
+ 对比两台服务器上同一文件的内容差异。
1685
+
1686
+ 示例:
1687
+ ssh-mcp diff-servers --server-a web1 --server-b web2 --path /etc/nginx/nginx.conf
1688
+ `);
1689
+ return;
1690
+ }
1691
+ const a = optStr(opts, "server-a");
1692
+ const b = optStr(opts, "server-b");
1693
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1694
+ if (!a)
1695
+ die("缺少 --server-a");
1696
+ if (!b)
1697
+ die("缺少 --server-b");
1698
+ if (!path)
1699
+ die("缺少 --path");
1700
+ try {
1701
+ const servers = loadServers();
1702
+ const cfgA = servers.get(a);
1703
+ const cfgB = servers.get(b);
1704
+ if (!cfgA)
1705
+ die(`未找到服务器 "${a}"`);
1706
+ if (!cfgB)
1707
+ die(`未找到服务器 "${b}"`);
1708
+ const d = await diffServers(cfgA, cfgB, path);
1709
+ if (d.identical) {
1710
+ process.stdout.write(`${a} 和 ${b} 上 ${path} 内容一致。\n`);
1711
+ return;
1712
+ }
1713
+ process.stdout.write(`--- ${a}:${path}\n+++ ${b}:${path}\n${d.diff}\n`);
1714
+ }
1715
+ catch (e) {
1716
+ die(`对比失败:${e.message}`);
1717
+ }
1718
+ }
1719
+ // ---- 脚本执行 CLI ----
1720
+ async function cmdExecScript(opts) {
1721
+ if (optBool(opts, "help")) {
1722
+ process.stdout.write(`用法: ssh-mcp exec-script --server <name> --script <path> [--remote <path>] [--timeout <ms>]
1723
+
1724
+ 上传本机脚本到远程服务器执行,完成后自动删除。
1725
+
1726
+ 示例:
1727
+ ssh-mcp exec-script -s prod-web --script ./deploy.sh
1728
+ ssh-mcp exec-script -s prod-web --script ./migrate.sh --remote /tmp/migrate.sh
1729
+ `);
1730
+ return;
1731
+ }
1732
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1733
+ const script = optStr(opts, "script");
1734
+ if (!serverName)
1735
+ die("缺少 --server");
1736
+ if (!script)
1737
+ die("缺少 --script");
1738
+ try {
1739
+ const servers = loadServers();
1740
+ const cfg = servers.get(serverName);
1741
+ if (!cfg)
1742
+ die(`未找到服务器 "${serverName}"`);
1743
+ const r = await execScript(cfg, script, optStr(opts, "remote") ?? "/tmp/ssh-mcp-script.sh", optNum(opts, "timeout") ?? 120_000);
1744
+ process.stdout.write(`退出码: ${r.exitCode}\n${r.stdout}\n`);
1745
+ if (r.stderr)
1746
+ process.stderr.write(r.stderr + "\n");
1747
+ }
1748
+ catch (e) {
1749
+ die(`脚本执行失败:${e.message}`);
1750
+ }
1751
+ }
1752
+ // ---- 快照 CLI ----
1753
+ async function cmdSnapshot(opts) {
1754
+ if (optBool(opts, "help")) {
1755
+ process.stdout.write(`用法: ssh-mcp snapshot --server <name> --dir <path> --output <file> [--exclude <pattern,...>]
1756
+
1757
+ 将远程目录通过 tar.gz 打包后流式下载到本机。
1758
+
1759
+ 示例:
1760
+ ssh-mcp snapshot -s prod-web --dir /var/log --output ./logs.tar.gz
1761
+ ssh-mcp snapshot -s prod-web --dir /opt/app --output ./app-backup.tar.gz --exclude "*.log,node_modules"
1762
+ `);
1763
+ return;
1764
+ }
1765
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1766
+ const dir = optStr(opts, "dir");
1767
+ const output = optStr(opts, "output");
1768
+ if (!serverName)
1769
+ die("缺少 --server");
1770
+ if (!dir)
1771
+ die("缺少 --dir");
1772
+ if (!output)
1773
+ die("缺少 --output");
1774
+ const excludes = (optStr(opts, "exclude") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1775
+ try {
1776
+ const servers = loadServers();
1777
+ const cfg = servers.get(serverName);
1778
+ if (!cfg)
1779
+ die(`未找到服务器 "${serverName}"`);
1780
+ const r = await snapshot(cfg, dir, output, excludes);
1781
+ process.stdout.write(`快照完成\n 服务器: ${r.server}:${r.remotePath}\n 本地: ${r.localFile}\n 大小: ${humanBytes(r.fileSize)}\n 耗时: ${(r.elapsedMs / 1000).toFixed(1)}s\n`);
1782
+ }
1783
+ catch (e) {
1784
+ die(`快照失败:${e.message}`);
1785
+ }
1786
+ }
1787
+ // ---- tail-f CLI ----
1788
+ async function cmdTailFollow(opts) {
1789
+ if (optBool(opts, "help")) {
1790
+ process.stdout.write(`用法: ssh-mcp tail-f --server <name> --path <path> [--interval <ms>]
1791
+
1792
+ 持续追踪远程文件的增长内容(SFTP 轮询模式)。在终端实时输出新增内容。
1793
+
1794
+ 选项:
1795
+ --server, -s <name> 目标服务器 name(必需)
1796
+ --path, -p <path> 要追踪的远程文件(必需)
1797
+ --interval <ms> 轮询间隔(默认 2000ms)
1798
+
1799
+ 示例:
1800
+ ssh-mcp tail-f -s prod-web -p /var/log/nginx/access.log
1801
+ ssh-mcp tail-f -s prod-web -p /var/log/app.log --interval 1000
1802
+ `);
1803
+ return;
1804
+ }
1805
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1806
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1807
+ if (!serverName)
1808
+ die("缺少 --server");
1809
+ if (!path)
1810
+ die("缺少 --path");
1811
+ try {
1812
+ const servers = loadServers();
1813
+ const cfg = servers.get(serverName);
1814
+ if (!cfg)
1815
+ die(`未找到服务器 "${serverName}"`);
1816
+ process.stdout.write(`追踪 ${cfg.name}:${path}(Ctrl+C 停止)\n`);
1817
+ await startTailFollow(cfg, path, optNum(opts, "interval") ?? 2000, (_id, chunk) => {
1818
+ process.stdout.write(chunk);
1819
+ });
1820
+ // keep alive; SIGINT will kill process
1821
+ await new Promise(() => { });
1822
+ }
1823
+ catch (e) {
1824
+ die(`tail 失败:${e.message}`);
1825
+ }
1826
+ }
1827
+ async function cmdStopTail(opts) {
1828
+ if (optBool(opts, "help")) {
1829
+ process.stdout.write(`用法: ssh-mcp stop-tail --id <id>
1830
+
1831
+ 停止 tail 追踪任务。
1832
+
1833
+ 示例:
1834
+ ssh-mcp stop-tail --id tail1
1835
+ `);
1836
+ return;
1837
+ }
1838
+ const id = optStr(opts, "id") ?? optStr(opts, "i");
1839
+ if (!id)
1840
+ die("缺少 --id");
1841
+ if (!stopTailFollow(id))
1842
+ die(`未找到 tail: ${id}`);
1843
+ process.stdout.write(`tail ${id} 已停止。\n`);
1844
+ }
1845
+ async function cmdListTails(_opts) {
1846
+ const all = listTailFollows();
1847
+ if (all.length === 0) {
1848
+ process.stdout.write("当前没有 tail 追踪任务。\n");
1849
+ return;
1850
+ }
1851
+ for (const t of all) {
1852
+ process.stdout.write(`[${t.id}] ${t.server}:${t.path} — ${t.state}(${humanBytes(t.seenBytes)} 已跟踪)\n`);
1853
+ }
1854
+ }
1855
+ // ---- watch CLI ----
1856
+ async function cmdWatch(opts, positional) {
1857
+ if (optBool(opts, "help")) {
1858
+ process.stdout.write(`用法: ssh-mcp watch --server <name> --interval <ms> [选项] <命令...>
1859
+
1860
+ 定时重复执行命令,自动高亮输出变化。
1861
+
1862
+ 选项:
1863
+ --server, -s <name> 目标服务器 name(必需)
1864
+ --interval <ms> 执行间隔毫秒数(必需)
1865
+ --timeout <ms> 每次命令超时(默认 10000)
1866
+ --command, -c <cmd> 要执行的命令(也可放在选项之后)
1867
+
1868
+ 示例:
1869
+ ssh-mcp watch -s prod-web --interval 5000 -c "ls -la /tmp"
1870
+ ssh-mcp watch -s prod-web --interval 2000 "date +%s ; wc -l /var/log/app.log"
1871
+ `);
1872
+ return;
1873
+ }
1874
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1875
+ const interval = optNum(opts, "interval");
1876
+ if (!serverName)
1877
+ die("缺少 --server");
1878
+ if (!interval)
1879
+ die("缺少 --interval");
1880
+ let command = optStr(opts, "command") ?? optStr(opts, "c");
1881
+ if (!command)
1882
+ command = positional.join(" ");
1883
+ if (!command)
1884
+ die("缺少命令");
1885
+ try {
1886
+ const servers = loadServers();
1887
+ const cfg = servers.get(serverName);
1888
+ if (!cfg)
1889
+ die(`未找到服务器 "${serverName}"`);
1890
+ process.stdout.write(`watch ${cfg.name} 每 ${interval}ms: ${command}(Ctrl+C 停止)\n`);
1891
+ let prev = "";
1892
+ startWatch(cfg, command, interval, (_id, iter) => {
1893
+ process.stdout.write(`\n=== ${new Date(iter.timestamp).toLocaleTimeString()} ===\n`);
1894
+ if (iter.changed) {
1895
+ process.stdout.write(`[变化]\n${iter.diff}\n`);
1896
+ prev = iter.stdout + iter.stderr;
1897
+ }
1898
+ else {
1899
+ process.stdout.write("(无变化)\n");
1900
+ process.stdout.write(iter.stdout);
1901
+ if (iter.stderr)
1902
+ process.stderr.write(iter.stderr);
1903
+ }
1904
+ }, optNum(opts, "timeout") ?? 10_000);
1905
+ await new Promise(() => { });
1906
+ }
1907
+ catch (e) {
1908
+ die(`watch 失败:${e.message}`);
1909
+ }
1910
+ }
1911
+ async function cmdStopWatch(opts) {
1912
+ const id = optStr(opts, "id") ?? optStr(opts, "i");
1913
+ if (!id)
1914
+ die("缺少 --id");
1915
+ if (!stopWatch(id))
1916
+ die(`未找到 watch: ${id}`);
1917
+ process.stdout.write(`watch ${id} 已停止。\n`);
1918
+ }
1919
+ async function cmdListWatches(_opts) {
1920
+ const all = listWatches();
1921
+ if (all.length === 0) {
1922
+ process.stdout.write("当前没有 watch 任务。\n");
1923
+ return;
1924
+ }
1925
+ for (const w of all) {
1926
+ process.stdout.write(`[${w.id}] ${w.server}: ${w.command}(每 ${w.intervalMs}ms)— ${w.state}\n`);
1927
+ }
1928
+ }
1929
+ // ---- curl CLI ----
1930
+ async function cmdCurl(opts, positional) {
1931
+ if (optBool(opts, "help")) {
1932
+ process.stdout.write(`用法: ssh-mcp curl --server <name> [选项] <url>
1933
+
1934
+ 从远程服务器发起 HTTP 请求,以远程视角探测目标。
1935
+
1936
+ 选项:
1937
+ --server, -s <name> 目标服务器 name(必需)
1938
+ --method, -X <method> HTTP 方法(默认 GET)
1939
+ --header, -H <hdr> 请求头(可重复使用)
1940
+ --data, -d <body> 请求体
1941
+ --timeout <ms> 超时毫秒数(默认 30000)
1942
+
1943
+ 示例:
1944
+ ssh-mcp curl -s prod-web http://localhost:8080/health
1945
+ ssh-mcp curl -s prod-web -X POST -H 'Content-Type: application/json' -d '{"a":1}' http://api.internal/users
1946
+ `);
1947
+ return;
1948
+ }
1949
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1950
+ let url = positional[0] ?? optStr(opts, "url");
1951
+ if (!serverName)
1952
+ die("缺少 --server");
1953
+ if (!url)
1954
+ die("缺少 URL");
1955
+ try {
1956
+ const servers = loadServers();
1957
+ const cfg = servers.get(serverName);
1958
+ if (!cfg)
1959
+ die(`未找到服务器 "${serverName}"`);
1960
+ const r = await httpRequest(cfg, url, optStr(opts, "method") ?? optStr(opts, "X") ?? "GET", parseHeaderArgs(opts), optStr(opts, "data") ?? optStr(opts, "d"), optNum(opts, "timeout") ?? 30_000);
1961
+ process.stdout.write(`HTTP ${r.httpCode}(耗时 ${r.duration})\n${r.body}\n`);
1962
+ }
1963
+ catch (e) {
1964
+ die(`HTTP 请求失败:${e.message}`);
1965
+ }
1966
+ }
1967
+ function parseHeaderArgs(opts) {
1968
+ const headers = {};
1969
+ const hv = opts.get("header") ?? opts.get("H");
1970
+ if (typeof hv === "string") {
1971
+ const colon = hv.indexOf(":");
1972
+ if (colon > 0)
1973
+ headers[hv.slice(0, colon).trim()] = hv.slice(colon + 1).trim();
1974
+ }
1975
+ // For multi-header support, the current arg parser doesn't support repeated flags.
1976
+ // Accept comma-separated: -H "a:1,b:2"
1977
+ const multi = (typeof hv === "string" ? hv : "");
1978
+ return headers;
1979
+ }
1980
+ // ---- env CLI ----
1981
+ async function cmdEnv(opts) {
1982
+ if (optBool(opts, "help")) {
1983
+ process.stdout.write(`用法: ssh-mcp env --server <name> [--process <name>]
1984
+
1985
+ 收集远程服务器环境信息:环境变量、登录用户、网络端口、进程。
1986
+
1987
+ 示例:
1988
+ ssh-mcp env -s prod-web
1989
+ ssh-mcp env -s prod-web --process nginx
1990
+ `);
1991
+ return;
1992
+ }
1993
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1994
+ if (!serverName)
1995
+ die("缺少 --server");
1996
+ try {
1997
+ const servers = loadServers();
1998
+ const cfg = servers.get(serverName);
1999
+ if (!cfg)
2000
+ die(`未找到服务器 "${serverName}"`);
2001
+ const e = await getRemoteEnv(cfg, optStr(opts, "process"));
2002
+ process.stdout.write(`=== ${e.server} 环境信息 ===\n`);
2003
+ if (e.procInfo)
2004
+ process.stdout.write(`进程: PID=${e.procInfo.pid} PPID=${e.procInfo.ppid} CMD=${e.procInfo.cmdline}\n`);
2005
+ process.stdout.write(`登录用户:\n${e.users}\n`);
2006
+ process.stdout.write(`网络监听:\n${e.network}\n`);
2007
+ const envKeys = Object.keys(e.envVars);
2008
+ if (envKeys.length) {
2009
+ process.stdout.write(`环境变量 (${envKeys.length} 个):\n`);
2010
+ for (const [k, v] of Object.entries(e.envVars))
2011
+ process.stdout.write(` ${k}=${v}\n`);
2012
+ }
2013
+ }
2014
+ catch (e) {
2015
+ die(`收集失败:${e.message}`);
2016
+ }
2017
+ }
1207
2018
  function loadServersOrDie() {
1208
2019
  try {
1209
2020
  return { servers: loadServers(), error: null };
@@ -1250,6 +2061,20 @@ async function main() {
1250
2061
  case "stat": return cmdStat(options);
1251
2062
  case "rm": return cmdRm(options);
1252
2063
  case "mkdir": return cmdMkdir(options);
2064
+ case "health": return cmdHealth(options);
2065
+ case "cert-info": return cmdCertInfo(options);
2066
+ case "copy-between": return cmdCopyBetween(options);
2067
+ case "diff-servers": return cmdDiffServers(options);
2068
+ case "exec-script": return cmdExecScript(options);
2069
+ case "snapshot": return cmdSnapshot(options);
2070
+ case "tail-f": return cmdTailFollow(options);
2071
+ case "stop-tail": return cmdStopTail(options);
2072
+ case "list-tails": return cmdListTails(options);
2073
+ case "watch": return cmdWatch(options, positional);
2074
+ case "stop-watch": return cmdStopWatch(options);
2075
+ case "list-watches": return cmdListWatches(options);
2076
+ case "curl": return cmdCurl(options, positional);
2077
+ case "env": return cmdEnv(options);
1253
2078
  default:
1254
2079
  die(`未知子命令: ${subcommand}\n运行 ssh-mcp --help 查看可用命令。`);
1255
2080
  }