@pyrokine/mcp-ssh 1.0.0 → 1.1.2

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.
@@ -7,12 +7,12 @@
7
7
  * - 自动重连
8
8
  * - 会话持久化
9
9
  */
10
- import { Client } from 'ssh2';
10
+ import xterm from '@xterm/headless';
11
11
  import * as fs from 'fs';
12
+ import * as net from 'net';
12
13
  import * as path from 'path';
13
- import xterm from '@xterm/headless';
14
+ import { Client } from 'ssh2';
14
15
  const Terminal = xterm.Terminal;
15
- import * as net from 'net';
16
16
  export class SessionManager {
17
17
  sessions = new Map();
18
18
  ptySessions = new Map();
@@ -29,18 +29,6 @@ export class SessionManager {
29
29
  this.persistPath = persistPath || path.join(process.env.HOME || '/tmp', '.ssh-mcp-pro', 'sessions.json');
30
30
  this.ensurePersistDir();
31
31
  }
32
- ensurePersistDir() {
33
- const dir = path.dirname(this.persistPath);
34
- if (!fs.existsSync(dir)) {
35
- fs.mkdirSync(dir, { recursive: true });
36
- }
37
- }
38
- /**
39
- * 生成连接别名
40
- */
41
- generateAlias(config) {
42
- return config.alias || `${config.username}@${config.host}:${config.port}`;
43
- }
44
32
  /**
45
33
  * 建立 SSH 连接
46
34
  */
@@ -114,7 +102,8 @@ export class SessionManager {
114
102
  if (session.reconnectAttempts < this.maxReconnectAttempts) {
115
103
  session.reconnectAttempts++;
116
104
  setTimeout(() => {
117
- this.reconnect(alias).catch(() => { });
105
+ this.reconnect(alias).catch(() => {
106
+ });
118
107
  }, 5000); // 5 秒后重连
119
108
  }
120
109
  }
@@ -122,25 +111,6 @@ export class SessionManager {
122
111
  client.connect(connectConfig);
123
112
  });
124
113
  }
125
- /**
126
- * 通过跳板机转发连接
127
- */
128
- forwardConnection(jumpClient, targetHost, targetPort) {
129
- return new Promise((resolve, reject) => {
130
- jumpClient.forwardOut('127.0.0.1', 0, targetHost, targetPort, (err, stream) => {
131
- if (err)
132
- reject(err);
133
- else
134
- resolve(stream);
135
- });
136
- });
137
- }
138
- /**
139
- * 检查连接是否存活
140
- */
141
- isAlive(session) {
142
- return session.connected;
143
- }
144
114
  /**
145
115
  * 重新连接
146
116
  */
@@ -152,7 +122,8 @@ export class SessionManager {
152
122
  try {
153
123
  session.client.end();
154
124
  }
155
- catch { }
125
+ catch {
126
+ }
156
127
  await this.connect(session.config);
157
128
  }
158
129
  /**
@@ -164,7 +135,8 @@ export class SessionManager {
164
135
  try {
165
136
  session.client.end();
166
137
  }
167
- catch { }
138
+ catch {
139
+ }
168
140
  this.sessions.delete(alias);
169
141
  this.persistSessions();
170
142
  return true;
@@ -212,12 +184,6 @@ export class SessionManager {
212
184
  }
213
185
  return result;
214
186
  }
215
- /**
216
- * 转义 shell 参数(使用单引号方式)
217
- */
218
- escapeShellArg(s) {
219
- return `'${s.replace(/'/g, "'\\''")}'`;
220
- }
221
187
  /**
222
188
  * 执行命令
223
189
  */
@@ -274,8 +240,9 @@ export class SessionManager {
274
240
  reject(new Error(`Command timed out after ${timeout}ms`));
275
241
  }, timeout);
276
242
  stream.on('close', (code) => {
277
- if (timeoutId)
243
+ if (timeoutId) {
278
244
  clearTimeout(timeoutId);
245
+ }
279
246
  resolve({
280
247
  success: code === 0,
281
248
  stdout: stdoutTruncated ? stdout + '\n... [truncated]' : stdout,
@@ -311,28 +278,24 @@ export class SessionManager {
311
278
  });
312
279
  });
313
280
  }
314
- /**
315
- * 校验用户名(只允许字母、数字、下划线、连字符)
316
- */
317
- isValidUsername(username) {
318
- return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(username);
319
- }
320
- /**
321
- * 校验环境变量名(只允许字母、数字、下划线,不能以数字开头)
322
- */
323
- isValidEnvKey(key) {
324
- return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key);
325
- }
326
281
  /**
327
282
  * 以其他用户身份执行命令
283
+ * @param loadProfile 是否加载用户的 shell 配置(默认 true)。
284
+ * su -c 创建非交互式 shell,不会自动执行 rc 文件,
285
+ * 但大多数用户的环境变量设置在 rc 文件中,因此默认加载。
286
+ * 支持 bash(.bashrc)、zsh(.zshrc) 及其他 shell(.profile)。
328
287
  */
329
288
  async execAsUser(alias, command, targetUser, options = {}) {
330
289
  // 校验用户名防止注入
331
290
  if (!this.isValidUsername(targetUser)) {
332
291
  throw new Error(`Invalid username: ${targetUser}`);
333
292
  }
334
- const suCommand = `su - ${targetUser} -c ${this.escapeShellArg(command)}`;
335
- return this.exec(alias, suCommand, options);
293
+ const { loadProfile = true, ...execOpts } = options;
294
+ const wrappedCommand = loadProfile
295
+ ? `${this.getLoadProfileCommand()}${command}`
296
+ : command;
297
+ const suCommand = `su - ${targetUser} -c ${this.escapeShellArg(wrappedCommand)}`;
298
+ return this.exec(alias, suCommand, execOpts);
336
299
  }
337
300
  /**
338
301
  * 使用 sudo 执行命令
@@ -355,36 +318,15 @@ export class SessionManager {
355
318
  const session = this.getSession(alias);
356
319
  return new Promise((resolve, reject) => {
357
320
  session.client.sftp((err, sftp) => {
358
- if (err)
321
+ if (err) {
359
322
  reject(err);
360
- else
323
+ }
324
+ else {
361
325
  resolve(sftp);
326
+ }
362
327
  });
363
328
  });
364
329
  }
365
- /**
366
- * 持久化会话信息
367
- */
368
- persistSessions() {
369
- const data = [];
370
- for (const [alias, session] of this.sessions) {
371
- // 不保存敏感信息(密码、密钥)
372
- data.push({
373
- alias,
374
- host: session.config.host,
375
- port: session.config.port || 22,
376
- username: session.config.username,
377
- connectedAt: session.connectedAt,
378
- env: session.config.env,
379
- });
380
- }
381
- try {
382
- fs.writeFileSync(this.persistPath, JSON.stringify(data, null, 2));
383
- }
384
- catch (e) {
385
- // 忽略写入错误
386
- }
387
- }
388
330
  /**
389
331
  * 加载持久化的会话信息(仅用于显示,不自动重连)
390
332
  */
@@ -394,16 +336,10 @@ export class SessionManager {
394
336
  return JSON.parse(fs.readFileSync(this.persistPath, 'utf-8'));
395
337
  }
396
338
  }
397
- catch { }
339
+ catch {
340
+ }
398
341
  return [];
399
342
  }
400
- // ========== PTY 会话管理 ==========
401
- /**
402
- * 生成 PTY 会话 ID
403
- */
404
- generatePtyId() {
405
- return `pty_${++this.ptyIdCounter}_${Date.now()}`;
406
- }
407
343
  /**
408
344
  * 启动持久化 PTY 会话
409
345
  */
@@ -456,8 +392,9 @@ export class SessionManager {
456
392
  };
457
393
  // 监听输出数据
458
394
  stream.on('data', (data) => {
459
- if (!ptySession.active)
395
+ if (!ptySession.active) {
460
396
  return;
397
+ }
461
398
  const chunk = data.toString('utf-8');
462
399
  // 写入终端仿真器(解析 ANSI 序列)
463
400
  terminal.write(chunk);
@@ -492,24 +429,6 @@ export class SessionManager {
492
429
  }
493
430
  return ptySession.stream.write(data);
494
431
  }
495
- /**
496
- * 从终端仿真器获取当前屏幕内容
497
- */
498
- getScreenContent(terminal) {
499
- const buffer = terminal.buffer.active;
500
- const lines = [];
501
- for (let i = 0; i < terminal.rows; i++) {
502
- const line = buffer.getLine(i);
503
- if (line) {
504
- lines.push(line.translateToString(true));
505
- }
506
- }
507
- // 移除尾部空行
508
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
509
- lines.pop();
510
- }
511
- return lines.join('\n');
512
- }
513
432
  /**
514
433
  * 读取 PTY 输出
515
434
  * @param mode 'screen' 返回当前屏幕内容,'raw' 返回原始 ANSI 流
@@ -571,11 +490,13 @@ export class SessionManager {
571
490
  try {
572
491
  ptySession.stream.close();
573
492
  }
574
- catch { }
493
+ catch {
494
+ }
575
495
  try {
576
496
  ptySession.terminal.dispose();
577
497
  }
578
- catch { }
498
+ catch {
499
+ }
579
500
  ptySession.active = false;
580
501
  this.ptySessions.delete(ptyId);
581
502
  return true;
@@ -612,13 +533,6 @@ export class SessionManager {
612
533
  }
613
534
  return count;
614
535
  }
615
- // ========== 端口转发 ==========
616
- /**
617
- * 生成端口转发 ID
618
- */
619
- generateForwardId() {
620
- return `fwd_${++this.forwardIdCounter}_${Date.now()}`;
621
- }
622
536
  /**
623
537
  * 创建本地端口转发
624
538
  * 本地监听 localHost:localPort,转发到远程 remoteHost:remotePort
@@ -688,6 +602,174 @@ export class SessionManager {
688
602
  });
689
603
  });
690
604
  }
605
+ // ========== PTY 会话管理 ==========
606
+ /**
607
+ * 关闭端口转发
608
+ */
609
+ forwardClose(forwardId) {
610
+ const fwdSession = this.forwardSessions.get(forwardId);
611
+ if (!fwdSession) {
612
+ return false;
613
+ }
614
+ fwdSession.active = false;
615
+ if (fwdSession.type === 'local' && fwdSession.server) {
616
+ try {
617
+ fwdSession.server.close();
618
+ }
619
+ catch {
620
+ }
621
+ }
622
+ else if (fwdSession.type === 'remote') {
623
+ const session = this.sessions.get(fwdSession.alias);
624
+ if (session) {
625
+ try {
626
+ session.client.unforwardIn(fwdSession.remoteHost, fwdSession.remotePort);
627
+ }
628
+ catch {
629
+ }
630
+ // 检查是否需要移除共享 dispatcher
631
+ this.removeTcpDispatcherIfEmpty(session, fwdSession.alias);
632
+ }
633
+ }
634
+ this.forwardSessions.delete(forwardId);
635
+ return true;
636
+ }
637
+ /**
638
+ * 列出所有端口转发
639
+ */
640
+ forwardList() {
641
+ const result = [];
642
+ for (const [id, fwd] of this.forwardSessions) {
643
+ result.push({
644
+ id,
645
+ alias: fwd.alias,
646
+ type: fwd.type,
647
+ localHost: fwd.localHost,
648
+ localPort: fwd.localPort,
649
+ remoteHost: fwd.remoteHost,
650
+ remotePort: fwd.remotePort,
651
+ createdAt: fwd.createdAt,
652
+ active: fwd.active,
653
+ });
654
+ }
655
+ return result;
656
+ }
657
+ ensurePersistDir() {
658
+ const dir = path.dirname(this.persistPath);
659
+ if (!fs.existsSync(dir)) {
660
+ fs.mkdirSync(dir, { recursive: true });
661
+ }
662
+ }
663
+ /**
664
+ * 生成连接别名
665
+ */
666
+ generateAlias(config) {
667
+ return config.alias || `${config.username}@${config.host}:${config.port}`;
668
+ }
669
+ /**
670
+ * 通过跳板机转发连接
671
+ */
672
+ forwardConnection(jumpClient, targetHost, targetPort) {
673
+ return new Promise((resolve, reject) => {
674
+ jumpClient.forwardOut('127.0.0.1', 0, targetHost, targetPort, (err, stream) => {
675
+ if (err) {
676
+ reject(err);
677
+ }
678
+ else {
679
+ resolve(stream);
680
+ }
681
+ });
682
+ });
683
+ }
684
+ /**
685
+ * 检查连接是否存活
686
+ */
687
+ isAlive(session) {
688
+ return session.connected;
689
+ }
690
+ /**
691
+ * 转义 shell 参数(使用单引号方式)
692
+ */
693
+ escapeShellArg(s) {
694
+ return `'${s.replace(/'/g, '\'\\\'\'')}'`;
695
+ }
696
+ /**
697
+ * 校验用户名(只允许字母、数字、下划线、连字符)
698
+ */
699
+ isValidUsername(username) {
700
+ return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(username);
701
+ }
702
+ /**
703
+ * 校验环境变量名(只允许字母、数字、下划线,不能以数字开头)
704
+ */
705
+ isValidEnvKey(key) {
706
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key);
707
+ }
708
+ // ========== 端口转发 ==========
709
+ /**
710
+ * 根据用户 shell 类型生成加载配置文件的命令
711
+ * bash → .bashrc, zsh → .zshrc, 其他 → .profile
712
+ */
713
+ getLoadProfileCommand() {
714
+ return 'case "$(basename "$SHELL" 2>/dev/null)" in ' +
715
+ 'bash) [ -f ~/.bashrc ] && . ~/.bashrc ;; ' +
716
+ 'zsh) [ -f ~/.zshrc ] && . ~/.zshrc ;; ' +
717
+ '*) [ -f ~/.profile ] && . ~/.profile ;; ' +
718
+ 'esac 2>/dev/null; ';
719
+ }
720
+ /**
721
+ * 持久化会话信息
722
+ */
723
+ persistSessions() {
724
+ const data = [];
725
+ for (const [alias, session] of this.sessions) {
726
+ // 不保存敏感信息(密码、密钥)
727
+ data.push({
728
+ alias,
729
+ host: session.config.host,
730
+ port: session.config.port || 22,
731
+ username: session.config.username,
732
+ connectedAt: session.connectedAt,
733
+ env: session.config.env,
734
+ });
735
+ }
736
+ try {
737
+ fs.writeFileSync(this.persistPath, JSON.stringify(data, null, 2));
738
+ }
739
+ catch (e) {
740
+ // 忽略写入错误
741
+ }
742
+ }
743
+ /**
744
+ * 生成 PTY 会话 ID
745
+ */
746
+ generatePtyId() {
747
+ return `pty_${++this.ptyIdCounter}_${Date.now()}`;
748
+ }
749
+ /**
750
+ * 从终端仿真器获取当前屏幕内容
751
+ */
752
+ getScreenContent(terminal) {
753
+ const buffer = terminal.buffer.active;
754
+ const lines = [];
755
+ for (let i = 0; i < terminal.rows; i++) {
756
+ const line = buffer.getLine(i);
757
+ if (line) {
758
+ lines.push(line.translateToString(true));
759
+ }
760
+ }
761
+ // 移除尾部空行
762
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
763
+ lines.pop();
764
+ }
765
+ return lines.join('\n');
766
+ }
767
+ /**
768
+ * 生成端口转发 ID
769
+ */
770
+ generateForwardId() {
771
+ return `fwd_${++this.forwardIdCounter}_${Date.now()}`;
772
+ }
691
773
  /**
692
774
  * 确保 SSH session 有共享的 tcp connection dispatcher
693
775
  * 所有 remote forward 共用一个 dispatcher,根据 destIP/destPort 路由
@@ -738,55 +820,6 @@ export class SessionManager {
738
820
  session.client.removeListener('tcp connection', session.tcpDispatcher);
739
821
  session.tcpDispatcher = undefined;
740
822
  }
741
- /**
742
- * 关闭端口转发
743
- */
744
- forwardClose(forwardId) {
745
- const fwdSession = this.forwardSessions.get(forwardId);
746
- if (!fwdSession) {
747
- return false;
748
- }
749
- fwdSession.active = false;
750
- if (fwdSession.type === 'local' && fwdSession.server) {
751
- try {
752
- fwdSession.server.close();
753
- }
754
- catch { }
755
- }
756
- else if (fwdSession.type === 'remote') {
757
- const session = this.sessions.get(fwdSession.alias);
758
- if (session) {
759
- try {
760
- session.client.unforwardIn(fwdSession.remoteHost, fwdSession.remotePort);
761
- }
762
- catch { }
763
- // 检查是否需要移除共享 dispatcher
764
- this.removeTcpDispatcherIfEmpty(session, fwdSession.alias);
765
- }
766
- }
767
- this.forwardSessions.delete(forwardId);
768
- return true;
769
- }
770
- /**
771
- * 列出所有端口转发
772
- */
773
- forwardList() {
774
- const result = [];
775
- for (const [id, fwd] of this.forwardSessions) {
776
- result.push({
777
- id,
778
- alias: fwd.alias,
779
- type: fwd.type,
780
- localHost: fwd.localHost,
781
- localPort: fwd.localPort,
782
- remoteHost: fwd.remoteHost,
783
- remotePort: fwd.remotePort,
784
- createdAt: fwd.createdAt,
785
- active: fwd.active,
786
- });
787
- }
788
- return result;
789
- }
790
823
  }
791
824
  // 全局单例
792
825
  export const sessionManager = new SessionManager();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SSH Config Parser
3
+ *
4
+ * 解析 ~/.ssh/config 文件,提取 Host 配置
5
+ * 支持:
6
+ * - Host 多别名(Host a b c)
7
+ * - Host * 全局默认配置继承
8
+ * - ProxyJump 解析(支持 user@host:port 格式)
9
+ */
10
+ export interface SSHConfigHost {
11
+ host: string;
12
+ hostName?: string;
13
+ user?: string;
14
+ port?: number;
15
+ identityFile?: string;
16
+ proxyJump?: string;
17
+ }
18
+ /** ProxyJump 解析结果 */
19
+ export interface ParsedProxyJump {
20
+ user?: string;
21
+ host: string;
22
+ port?: number;
23
+ }
24
+ /**
25
+ * 解析 ProxyJump 字符串
26
+ * 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
27
+ * 注意:只解析第一跳,不支持逗号分隔的多跳链路
28
+ */
29
+ export declare function parseProxyJump(proxyJump: string): ParsedProxyJump | null;
30
+ /**
31
+ * 解析 SSH config 文件
32
+ * 支持 Host 多别名和 Host * 继承
33
+ * 跳过 Match 块(避免条件配置被误应用)
34
+ */
35
+ export declare function parseSSHConfig(configPath?: string): SSHConfigHost[];
36
+ /**
37
+ * 根据 Host 名称获取配置
38
+ */
39
+ export declare function getHostConfig(hostName: string, configPath?: string): SSHConfigHost | null;