@pyrokine/mcp-ssh 1.1.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.
@@ -8,975 +8,1016 @@
8
8
  * - 会话持久化
9
9
  */
10
10
 
11
- import { Client, ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2';
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import xterm from '@xterm/headless';
15
- const Terminal = xterm.Terminal as typeof import('@xterm/headless').Terminal;
16
- type TerminalType = import('@xterm/headless').Terminal;
17
- import * as net from 'net';
11
+ import xterm from '@xterm/headless'
12
+ import * as fs from 'fs'
13
+ import * as net from 'net'
14
+ import * as path from 'path'
15
+ import {Client, ClientChannel, ConnectConfig, SFTPWrapper} from 'ssh2'
18
16
  import {
19
- SSHConnectionConfig,
20
- SSHSessionInfo,
21
- ExecOptions,
22
- ExecResult,
23
- PersistedSession,
24
- PtyOptions,
25
- PtySessionInfo,
26
- PortForwardConfig,
27
- PortForwardInfo
28
- } from './types.js';
17
+ ExecOptions,
18
+ ExecResult,
19
+ PersistedSession,
20
+ PortForwardInfo,
21
+ PtyOptions,
22
+ PtySessionInfo,
23
+ SSHConnectionConfig,
24
+ SSHSessionInfo,
25
+ } from './types.js'
26
+
27
+ const Terminal = xterm.Terminal as typeof import('@xterm/headless').Terminal
28
+ type TerminalType = import('@xterm/headless').Terminal;
29
29
 
30
30
  interface SSHSession {
31
- client: Client;
32
- config: SSHConnectionConfig;
33
- connectedAt: number;
34
- lastUsedAt: number;
35
- reconnectAttempts: number;
36
- connected: boolean; // 通过事件监听维护的连接状态
37
- tcpDispatcher?: TcpConnectionHandler; // remote forward 共享的 dispatcher
31
+ client: Client;
32
+ config: SSHConnectionConfig;
33
+ connectedAt: number;
34
+ lastUsedAt: number;
35
+ reconnectAttempts: number;
36
+ connected: boolean; // 通过事件监听维护的连接状态
37
+ tcpDispatcher?: TcpConnectionHandler; // remote forward 共享的 dispatcher
38
38
  }
39
39
 
40
40
  interface PtySession {
41
- id: string;
42
- alias: string;
43
- command: string;
44
- stream: ClientChannel;
45
- terminal: TerminalType; // xterm headless 终端仿真器
46
- rows: number;
47
- cols: number;
48
- createdAt: number;
49
- lastReadAt: number;
50
- rawBuffer: string; // 原始 ANSI 流
51
- maxBufferSize: number;
52
- active: boolean;
41
+ id: string;
42
+ alias: string;
43
+ command: string;
44
+ stream: ClientChannel;
45
+ terminal: TerminalType; // xterm headless 终端仿真器
46
+ rows: number;
47
+ cols: number;
48
+ createdAt: number;
49
+ lastReadAt: number;
50
+ rawBuffer: string; // 原始 ANSI 流
51
+ maxBufferSize: number;
52
+ active: boolean;
53
53
  }
54
54
 
55
55
  // tcp connection 事件处理函数类型
56
56
  type TcpConnectionHandler = (
57
- info: { destIP: string; destPort: number; srcIP: string; srcPort: number },
58
- accept: () => ClientChannel,
59
- reject: () => void
57
+ info: { destIP: string; destPort: number; srcIP: string; srcPort: number },
58
+ accept: () => ClientChannel,
59
+ reject: () => void,
60
60
  ) => void;
61
61
 
62
62
  interface ForwardSession {
63
- id: string;
64
- alias: string;
65
- type: 'local' | 'remote';
66
- localHost: string;
67
- localPort: number;
68
- remoteHost: string;
69
- remotePort: number;
70
- server?: net.Server; // 本地转发的 TCP 服务器
71
- createdAt: number;
72
- active: boolean;
63
+ id: string;
64
+ alias: string;
65
+ type: 'local' | 'remote';
66
+ localHost: string;
67
+ localPort: number;
68
+ remoteHost: string;
69
+ remotePort: number;
70
+ server?: net.Server; // 本地转发的 TCP 服务器
71
+ createdAt: number;
72
+ active: boolean;
73
73
  }
74
74
 
75
75
  export class SessionManager {
76
- private sessions: Map<string, SSHSession> = new Map();
77
- private ptySessions: Map<string, PtySession> = new Map();
78
- private forwardSessions: Map<string, ForwardSession> = new Map();
79
- private ptyIdCounter = 0;
80
- private forwardIdCounter = 0;
81
- private persistPath: string;
82
- private defaultKeepaliveInterval = 30000; // 30秒
83
- private defaultKeepaliveCountMax = 3;
84
- private defaultTimeout = 30000; // 30秒
85
- private maxReconnectAttempts = 3;
86
- private defaultPtyBufferSize = 1024 * 1024; // 1MB
87
-
88
- constructor(persistPath?: string) {
89
- this.persistPath = persistPath || path.join(
90
- process.env.HOME || '/tmp',
91
- '.ssh-mcp-pro',
92
- 'sessions.json'
93
- );
94
- this.ensurePersistDir();
95
- }
96
-
97
- private ensurePersistDir(): void {
98
- const dir = path.dirname(this.persistPath);
99
- if (!fs.existsSync(dir)) {
100
- fs.mkdirSync(dir, { recursive: true });
101
- }
102
- }
103
-
104
- /**
105
- * 生成连接别名
106
- */
107
- private generateAlias(config: SSHConnectionConfig): string {
108
- return config.alias || `${config.username}@${config.host}:${config.port}`;
109
- }
110
-
111
- /**
112
- * 建立 SSH 连接
113
- */
114
- async connect(config: SSHConnectionConfig): Promise<string> {
115
- const alias = this.generateAlias(config);
116
-
117
- // 检查是否已有活跃连接
118
- const existing = this.sessions.get(alias);
119
- if (existing && this.isAlive(existing)) {
120
- existing.lastUsedAt = Date.now();
121
- return alias;
122
- }
123
-
124
- const client = new Client();
125
-
126
- // 构建连接配置
127
- const connectConfig: ConnectConfig = {
128
- host: config.host,
129
- port: config.port || 22,
130
- username: config.username,
131
- readyTimeout: config.readyTimeout || this.defaultTimeout,
132
- keepaliveInterval: config.keepaliveInterval || this.defaultKeepaliveInterval,
133
- keepaliveCountMax: config.keepaliveCountMax || this.defaultKeepaliveCountMax,
134
- };
135
-
136
- // 认证方式
137
- if (config.password) {
138
- connectConfig.password = config.password;
139
- }
140
- if (config.privateKeyPath) {
141
- connectConfig.privateKey = fs.readFileSync(config.privateKeyPath);
142
- }
143
- if (config.privateKey) {
144
- connectConfig.privateKey = config.privateKey;
145
- }
146
- if (config.passphrase) {
147
- connectConfig.passphrase = config.passphrase;
148
- }
149
-
150
- // 跳板机支持
151
- if (config.jumpHost) {
152
- const jumpAlias = await this.connect(config.jumpHost);
153
- const jumpSession = this.sessions.get(jumpAlias);
154
- if (jumpSession) {
155
- // 通过跳板机建立连接
156
- const stream = await this.forwardConnection(
157
- jumpSession.client,
158
- config.host,
159
- config.port || 22
160
- );
161
- connectConfig.sock = stream;
162
- }
163
- }
164
-
165
- return new Promise((resolve, reject) => {
166
- client.on('ready', () => {
167
- const session: SSHSession = {
168
- client,
169
- config,
170
- connectedAt: Date.now(),
171
- lastUsedAt: Date.now(),
172
- reconnectAttempts: 0,
173
- connected: true,
174
- };
175
- this.sessions.set(alias, session);
176
- this.persistSessions();
177
- resolve(alias);
178
- });
179
-
180
- client.on('error', (err) => {
181
- const session = this.sessions.get(alias);
182
- if (session) {
183
- session.connected = false;
76
+ private sessions: Map<string, SSHSession> = new Map()
77
+ private ptySessions: Map<string, PtySession> = new Map()
78
+ private forwardSessions: Map<string, ForwardSession> = new Map()
79
+ private ptyIdCounter = 0
80
+ private forwardIdCounter = 0
81
+ private persistPath: string
82
+ private defaultKeepaliveInterval = 30000 // 30秒
83
+ private defaultKeepaliveCountMax = 3
84
+ private defaultTimeout = 30000 // 30秒
85
+ private maxReconnectAttempts = 3
86
+ private defaultPtyBufferSize = 1024 * 1024 // 1MB
87
+
88
+ constructor(persistPath?: string) {
89
+ this.persistPath = persistPath || path.join(
90
+ process.env.HOME || '/tmp',
91
+ '.ssh-mcp-pro',
92
+ 'sessions.json',
93
+ )
94
+ this.ensurePersistDir()
95
+ }
96
+
97
+ /**
98
+ * 建立 SSH 连接
99
+ */
100
+ async connect(config: SSHConnectionConfig): Promise<string> {
101
+ const alias = this.generateAlias(config)
102
+
103
+ // 检查是否已有活跃连接
104
+ const existing = this.sessions.get(alias)
105
+ if (existing && this.isAlive(existing)) {
106
+ existing.lastUsedAt = Date.now()
107
+ return alias
108
+ }
109
+
110
+ const client = new Client()
111
+
112
+ // 构建连接配置
113
+ const connectConfig: ConnectConfig = {
114
+ host: config.host,
115
+ port: config.port || 22,
116
+ username: config.username,
117
+ readyTimeout: config.readyTimeout || this.defaultTimeout,
118
+ keepaliveInterval: config.keepaliveInterval || this.defaultKeepaliveInterval,
119
+ keepaliveCountMax: config.keepaliveCountMax || this.defaultKeepaliveCountMax,
120
+ }
121
+
122
+ // 认证方式
123
+ if (config.password) {
124
+ connectConfig.password = config.password
125
+ }
126
+ if (config.privateKeyPath) {
127
+ connectConfig.privateKey = fs.readFileSync(config.privateKeyPath)
128
+ }
129
+ if (config.privateKey) {
130
+ connectConfig.privateKey = config.privateKey
131
+ }
132
+ if (config.passphrase) {
133
+ connectConfig.passphrase = config.passphrase
134
+ }
135
+
136
+ // 跳板机支持
137
+ if (config.jumpHost) {
138
+ const jumpAlias = await this.connect(config.jumpHost)
139
+ const jumpSession = this.sessions.get(jumpAlias)
140
+ if (jumpSession) {
141
+ // 通过跳板机建立连接
142
+ const stream = await this.forwardConnection(
143
+ jumpSession.client,
144
+ config.host,
145
+ config.port || 22,
146
+ )
147
+ connectConfig.sock = stream
148
+ }
184
149
  }
185
- reject(new Error(`SSH connection failed: ${err.message}`));
186
- });
187
150
 
188
- client.on('close', () => {
189
- const session = this.sessions.get(alias);
151
+ return new Promise((resolve, reject) => {
152
+ client.on('ready', () => {
153
+ const session: SSHSession = {
154
+ client,
155
+ config,
156
+ connectedAt: Date.now(),
157
+ lastUsedAt: Date.now(),
158
+ reconnectAttempts: 0,
159
+ connected: true,
160
+ }
161
+ this.sessions.set(alias, session)
162
+ this.persistSessions()
163
+ resolve(alias)
164
+ })
165
+
166
+ client.on('error', (err) => {
167
+ const session = this.sessions.get(alias)
168
+ if (session) {
169
+ session.connected = false
170
+ }
171
+ reject(new Error(`SSH connection failed: ${err.message}`))
172
+ })
173
+
174
+ client.on('close', () => {
175
+ const session = this.sessions.get(alias)
176
+ if (session) {
177
+ session.connected = false
178
+ // 自动重连逻辑
179
+ if (session.reconnectAttempts < this.maxReconnectAttempts) {
180
+ session.reconnectAttempts++
181
+ setTimeout(() => {
182
+ this.reconnect(alias).catch(() => {
183
+ })
184
+ }, 5000) // 5 秒后重连
185
+ }
186
+ }
187
+ })
188
+
189
+ client.connect(connectConfig)
190
+ })
191
+ }
192
+
193
+ /**
194
+ * 重新连接
195
+ */
196
+ async reconnect(alias: string): Promise<void> {
197
+ const session = this.sessions.get(alias)
198
+ if (!session) {
199
+ throw new Error(`Session ${alias} not found`)
200
+ }
201
+
202
+ try {
203
+ session.client.end()
204
+ } catch {
205
+ }
206
+
207
+ await this.connect(session.config)
208
+ }
209
+
210
+ /**
211
+ * 断开连接
212
+ */
213
+ disconnect(alias: string): boolean {
214
+ const session = this.sessions.get(alias)
190
215
  if (session) {
191
- session.connected = false;
192
- // 自动重连逻辑
193
- if (session.reconnectAttempts < this.maxReconnectAttempts) {
194
- session.reconnectAttempts++;
195
- setTimeout(() => {
196
- this.reconnect(alias).catch(() => {});
197
- }, 5000); // 5 秒后重连
198
- }
199
- }
200
- });
201
-
202
- client.connect(connectConfig);
203
- });
204
- }
205
-
206
- /**
207
- * 通过跳板机转发连接
208
- */
209
- private forwardConnection(
210
- jumpClient: Client,
211
- targetHost: string,
212
- targetPort: number
213
- ): Promise<ClientChannel> {
214
- return new Promise((resolve, reject) => {
215
- jumpClient.forwardOut(
216
- '127.0.0.1',
217
- 0,
218
- targetHost,
219
- targetPort,
220
- (err, stream) => {
221
- if (err) reject(err);
222
- else resolve(stream);
223
- }
224
- );
225
- });
226
- }
227
-
228
- /**
229
- * 检查连接是否存活
230
- */
231
- private isAlive(session: SSHSession): boolean {
232
- return session.connected;
233
- }
234
-
235
- /**
236
- * 重新连接
237
- */
238
- async reconnect(alias: string): Promise<void> {
239
- const session = this.sessions.get(alias);
240
- if (!session) {
241
- throw new Error(`Session ${alias} not found`);
242
- }
243
-
244
- try {
245
- session.client.end();
246
- } catch {}
247
-
248
- await this.connect(session.config);
249
- }
250
-
251
- /**
252
- * 断开连接
253
- */
254
- disconnect(alias: string): boolean {
255
- const session = this.sessions.get(alias);
256
- if (session) {
257
- try {
258
- session.client.end();
259
- } catch {}
260
- this.sessions.delete(alias);
261
- this.persistSessions();
262
- return true;
263
- }
264
- return false;
265
- }
266
-
267
- /**
268
- * 断开所有连接
269
- */
270
- disconnectAll(): void {
271
- for (const alias of this.sessions.keys()) {
272
- this.disconnect(alias);
273
- }
274
- }
275
-
276
- /**
277
- * 获取会话
278
- */
279
- getSession(alias: string): SSHSession {
280
- const session = this.sessions.get(alias);
281
- if (!session) {
282
- throw new Error(`Session '${alias}' not found. Use ssh_connect first.`);
283
- }
284
- if (!this.isAlive(session)) {
285
- throw new Error(`Session '${alias}' is disconnected. Use ssh_connect to reconnect.`);
286
- }
287
- session.lastUsedAt = Date.now();
288
- return session;
289
- }
290
-
291
- /**
292
- * 列出所有会话
293
- */
294
- listSessions(): SSHSessionInfo[] {
295
- const result: SSHSessionInfo[] = [];
296
- for (const [alias, session] of this.sessions) {
297
- result.push({
298
- alias,
299
- host: session.config.host,
300
- port: session.config.port || 22,
301
- username: session.config.username,
302
- connected: this.isAlive(session),
303
- connectedAt: session.connectedAt,
304
- lastUsedAt: session.lastUsedAt,
305
- env: session.config.env,
306
- });
307
- }
308
- return result;
309
- }
310
-
311
- /**
312
- * 转义 shell 参数(使用单引号方式)
313
- */
314
- private escapeShellArg(s: string): string {
315
- return `'${s.replace(/'/g, "'\\''")}'`;
316
- }
317
-
318
- /**
319
- * 执行命令
320
- */
321
- async exec(
322
- alias: string,
323
- command: string,
324
- options: ExecOptions = {}
325
- ): Promise<ExecResult> {
326
- const session = this.getSession(alias);
327
- const startTime = Date.now();
328
- const maxOutputSize = options.maxOutputSize || 10 * 1024 * 1024; // 默认 10MB
329
-
330
- // 构建完整命令(包含环境变量)
331
- let fullCommand = command;
332
- const env = { ...session.config.env, ...options.env };
333
-
334
- if (Object.keys(env).length > 0) {
335
- // 校验并过滤环境变量名
336
- const validEnvEntries = Object.entries(env).filter(([k]) => {
337
- if (!this.isValidEnvKey(k)) {
338
- // 静默忽略非法环境变量名
339
- return false;
340
- }
341
- return true;
342
- });
343
- if (validEnvEntries.length > 0) {
344
- const envStr = validEnvEntries
345
- .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
346
- .join('; ');
347
- fullCommand = `${envStr}; ${command}`;
348
- }
349
- }
350
-
351
- if (options.cwd) {
352
- fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`;
353
- }
354
-
355
- return new Promise((resolve, reject) => {
356
- const timeout = options.timeout || this.defaultTimeout;
357
- let timeoutId: NodeJS.Timeout | null = null;
358
- let stdout = '';
359
- let stderr = '';
360
- let stdoutTruncated = false;
361
- let stderrTruncated = false;
362
-
363
- const execOptions: any = {};
364
-
365
- // PTY 模式
366
- if (options.pty) {
367
- execOptions.pty = {
368
- rows: options.rows || 24,
369
- cols: options.cols || 80,
370
- term: options.term || 'xterm-256color',
371
- };
372
- }
373
-
374
- session.client.exec(fullCommand, execOptions, (err, stream) => {
375
- if (err) {
376
- reject(new Error(`Exec failed: ${err.message}`));
377
- return;
378
- }
379
-
380
- // 设置超时
381
- timeoutId = setTimeout(() => {
382
- stream.close();
383
- reject(new Error(`Command timed out after ${timeout}ms`));
384
- }, timeout);
385
-
386
- stream.on('close', (code: number) => {
387
- if (timeoutId) clearTimeout(timeoutId);
388
- resolve({
389
- success: code === 0,
390
- stdout: stdoutTruncated ? stdout + '\n... [truncated]' : stdout,
391
- stderr: stderrTruncated ? stderr + '\n... [truncated]' : stderr,
392
- exitCode: code,
393
- duration: Date.now() - startTime,
394
- });
395
- });
396
-
397
- stream.on('data', (data: Buffer) => {
398
- if (!stdoutTruncated) {
399
- const chunk = data.toString('utf-8');
400
- if (stdout.length + chunk.length > maxOutputSize) {
401
- stdout += chunk.slice(0, maxOutputSize - stdout.length);
402
- stdoutTruncated = true;
403
- } else {
404
- stdout += chunk;
216
+ try {
217
+ session.client.end()
218
+ } catch {
405
219
  }
406
- }
407
- });
408
-
409
- stream.stderr.on('data', (data: Buffer) => {
410
- if (!stderrTruncated) {
411
- const chunk = data.toString('utf-8');
412
- if (stderr.length + chunk.length > maxOutputSize) {
413
- stderr += chunk.slice(0, maxOutputSize - stderr.length);
414
- stderrTruncated = true;
415
- } else {
416
- stderr += chunk;
220
+ this.sessions.delete(alias)
221
+ this.persistSessions()
222
+ return true
223
+ }
224
+ return false
225
+ }
226
+
227
+ /**
228
+ * 断开所有连接
229
+ */
230
+ disconnectAll(): void {
231
+ for (const alias of this.sessions.keys()) {
232
+ this.disconnect(alias)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 获取会话
238
+ */
239
+ getSession(alias: string): SSHSession {
240
+ const session = this.sessions.get(alias)
241
+ if (!session) {
242
+ throw new Error(`Session '${alias}' not found. Use ssh_connect first.`)
243
+ }
244
+ if (!this.isAlive(session)) {
245
+ throw new Error(`Session '${alias}' is disconnected. Use ssh_connect to reconnect.`)
246
+ }
247
+ session.lastUsedAt = Date.now()
248
+ return session
249
+ }
250
+
251
+ /**
252
+ * 列出所有会话
253
+ */
254
+ listSessions(): SSHSessionInfo[] {
255
+ const result: SSHSessionInfo[] = []
256
+ for (const [alias, session] of this.sessions) {
257
+ result.push({
258
+ alias,
259
+ host: session.config.host,
260
+ port: session.config.port || 22,
261
+ username: session.config.username,
262
+ connected: this.isAlive(session),
263
+ connectedAt: session.connectedAt,
264
+ lastUsedAt: session.lastUsedAt,
265
+ env: session.config.env,
266
+ })
267
+ }
268
+ return result
269
+ }
270
+
271
+ /**
272
+ * 执行命令
273
+ */
274
+ async exec(
275
+ alias: string,
276
+ command: string,
277
+ options: ExecOptions = {},
278
+ ): Promise<ExecResult> {
279
+ const session = this.getSession(alias)
280
+ const startTime = Date.now()
281
+ const maxOutputSize = options.maxOutputSize || 10 * 1024 * 1024 // 默认 10MB
282
+
283
+ // 构建完整命令(包含环境变量)
284
+ let fullCommand = command
285
+ const env = {...session.config.env, ...options.env}
286
+
287
+ if (Object.keys(env).length > 0) {
288
+ // 校验并过滤环境变量名
289
+ const validEnvEntries = Object.entries(env).filter(([k]) => {
290
+ if (!this.isValidEnvKey(k)) {
291
+ // 静默忽略非法环境变量名
292
+ return false
293
+ }
294
+ return true
295
+ })
296
+ if (validEnvEntries.length > 0) {
297
+ const envStr = validEnvEntries
298
+ .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
299
+ .join('; ')
300
+ fullCommand = `${envStr}; ${command}`
417
301
  }
418
- }
419
- });
420
- });
421
- });
422
- }
423
-
424
- /**
425
- * 校验用户名(只允许字母、数字、下划线、连字符)
426
- */
427
- private isValidUsername(username: string): boolean {
428
- return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(username);
429
- }
430
-
431
- /**
432
- * 校验环境变量名(只允许字母、数字、下划线,不能以数字开头)
433
- */
434
- private isValidEnvKey(key: string): boolean {
435
- return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key);
436
- }
437
-
438
- /**
439
- * 以其他用户身份执行命令
440
- */
441
- async execAsUser(
442
- alias: string,
443
- command: string,
444
- targetUser: string,
445
- options: ExecOptions = {}
446
- ): Promise<ExecResult> {
447
- // 校验用户名防止注入
448
- if (!this.isValidUsername(targetUser)) {
449
- throw new Error(`Invalid username: ${targetUser}`);
450
- }
451
- const suCommand = `su - ${targetUser} -c ${this.escapeShellArg(command)}`;
452
- return this.exec(alias, suCommand, options);
453
- }
454
-
455
- /**
456
- * 使用 sudo 执行命令
457
- */
458
- async execSudo(
459
- alias: string,
460
- command: string,
461
- sudoPassword?: string,
462
- options: ExecOptions = {}
463
- ): Promise<ExecResult> {
464
- let sudoCommand: string;
465
- if (sudoPassword) {
466
- // 通过 stdin 传递密码(使用 escapeShellArg 转义)
467
- sudoCommand = `echo ${this.escapeShellArg(sudoPassword)} | sudo -S ${command}`;
468
- } else {
469
- sudoCommand = `sudo ${command}`;
470
- }
471
- return this.exec(alias, sudoCommand, options);
472
- }
473
-
474
- /**
475
- * 获取 SFTP 客户端
476
- */
477
- getSftp(alias: string): Promise<SFTPWrapper> {
478
- const session = this.getSession(alias);
479
- return new Promise((resolve, reject) => {
480
- session.client.sftp((err, sftp) => {
481
- if (err) reject(err);
482
- else resolve(sftp);
483
- });
484
- });
485
- }
486
-
487
- /**
488
- * 持久化会话信息
489
- */
490
- private persistSessions(): void {
491
- const data: PersistedSession[] = [];
492
- for (const [alias, session] of this.sessions) {
493
- // 不保存敏感信息(密码、密钥)
494
- data.push({
495
- alias,
496
- host: session.config.host,
497
- port: session.config.port || 22,
498
- username: session.config.username,
499
- connectedAt: session.connectedAt,
500
- env: session.config.env,
501
- });
502
- }
503
- try {
504
- fs.writeFileSync(this.persistPath, JSON.stringify(data, null, 2));
505
- } catch (e) {
506
- // 忽略写入错误
507
- }
508
- }
509
-
510
- /**
511
- * 加载持久化的会话信息(仅用于显示,不自动重连)
512
- */
513
- loadPersistedSessions(): PersistedSession[] {
514
- try {
515
- if (fs.existsSync(this.persistPath)) {
516
- return JSON.parse(fs.readFileSync(this.persistPath, 'utf-8'));
517
- }
518
- } catch {}
519
- return [];
520
- }
521
-
522
- // ========== PTY 会话管理 ==========
523
-
524
- /**
525
- * 生成 PTY 会话 ID
526
- */
527
- private generatePtyId(): string {
528
- return `pty_${++this.ptyIdCounter}_${Date.now()}`;
529
- }
530
-
531
- /**
532
- * 启动持久化 PTY 会话
533
- */
534
- async ptyStart(
535
- alias: string,
536
- command: string,
537
- options: PtyOptions = {}
538
- ): Promise<string> {
539
- const session = this.getSession(alias);
540
- const ptyId = this.generatePtyId();
541
-
542
- const rows = options.rows || 24;
543
- const cols = options.cols || 80;
544
- const term = options.term || 'xterm-256color';
545
- const maxBufferSize = options.bufferSize || this.defaultPtyBufferSize;
546
-
547
- // 构建完整命令
548
- let fullCommand = command;
549
- const env = { ...session.config.env, ...options.env };
550
-
551
- if (Object.keys(env).length > 0) {
552
- const envStr = Object.entries(env)
553
- .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
554
- .join('; ');
555
- fullCommand = `${envStr}; ${command}`;
556
- }
557
-
558
- if (options.cwd) {
559
- fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`;
560
- }
561
-
562
- return new Promise((resolve, reject) => {
563
- session.client.exec(
564
- fullCommand,
565
- {
566
- pty: { rows, cols, term },
567
- },
568
- (err, stream) => {
569
- if (err) {
570
- reject(new Error(`PTY start failed: ${err.message}`));
571
- return;
572
- }
573
-
574
- // 创建 xterm headless 终端仿真器
575
- const terminal = new Terminal({
576
- rows,
577
- cols,
578
- allowProposedApi: true,
579
- });
580
-
581
- const ptySession: PtySession = {
582
- id: ptyId,
583
- alias,
584
- command,
585
- stream,
586
- terminal,
587
- rows,
588
- cols,
589
- createdAt: Date.now(),
590
- lastReadAt: Date.now(),
591
- rawBuffer: '',
592
- maxBufferSize,
593
- active: true,
594
- };
595
-
596
- // 监听输出数据
597
- stream.on('data', (data: Buffer) => {
598
- if (!ptySession.active) return;
599
- const chunk = data.toString('utf-8');
600
- // 写入终端仿真器(解析 ANSI 序列)
601
- terminal.write(chunk);
602
- // 同时保留原始流(用于 raw 模式)
603
- ptySession.rawBuffer += chunk;
604
- if (ptySession.rawBuffer.length > maxBufferSize) {
605
- ptySession.rawBuffer = ptySession.rawBuffer.slice(-maxBufferSize);
302
+ }
303
+
304
+ if (options.cwd) {
305
+ fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`
306
+ }
307
+
308
+ return new Promise((resolve, reject) => {
309
+ const timeout = options.timeout || this.defaultTimeout
310
+ let timeoutId: NodeJS.Timeout | null = null
311
+ let stdout = ''
312
+ let stderr = ''
313
+ let stdoutTruncated = false
314
+ let stderrTruncated = false
315
+
316
+ const execOptions: any = {}
317
+
318
+ // PTY 模式
319
+ if (options.pty) {
320
+ execOptions.pty = {
321
+ rows: options.rows || 24,
322
+ cols: options.cols || 80,
323
+ term: options.term || 'xterm-256color',
324
+ }
325
+ }
326
+
327
+ session.client.exec(fullCommand, execOptions, (err, stream) => {
328
+ if (err) {
329
+ reject(new Error(`Exec failed: ${err.message}`))
330
+ return
331
+ }
332
+
333
+ // 设置超时
334
+ timeoutId = setTimeout(() => {
335
+ stream.close()
336
+ reject(new Error(`Command timed out after ${timeout}ms`))
337
+ }, timeout)
338
+
339
+ stream.on('close', (code: number) => {
340
+ if (timeoutId) {
341
+ clearTimeout(timeoutId)
342
+ }
343
+ resolve({
344
+ success: code === 0,
345
+ stdout: stdoutTruncated ? stdout + '\n... [truncated]' : stdout,
346
+ stderr: stderrTruncated ? stderr + '\n... [truncated]' : stderr,
347
+ exitCode: code,
348
+ duration: Date.now() - startTime,
349
+ })
350
+ })
351
+
352
+ stream.on('data', (data: Buffer) => {
353
+ if (!stdoutTruncated) {
354
+ const chunk = data.toString('utf-8')
355
+ if (stdout.length + chunk.length > maxOutputSize) {
356
+ stdout += chunk.slice(0, maxOutputSize - stdout.length)
357
+ stdoutTruncated = true
358
+ } else {
359
+ stdout += chunk
360
+ }
361
+ }
362
+ })
363
+
364
+ stream.stderr.on('data', (data: Buffer) => {
365
+ if (!stderrTruncated) {
366
+ const chunk = data.toString('utf-8')
367
+ if (stderr.length + chunk.length > maxOutputSize) {
368
+ stderr += chunk.slice(0, maxOutputSize - stderr.length)
369
+ stderrTruncated = true
370
+ } else {
371
+ stderr += chunk
372
+ }
373
+ }
374
+ })
375
+ })
376
+ })
377
+ }
378
+
379
+ /**
380
+ * 以其他用户身份执行命令
381
+ * @param loadProfile 是否加载用户的 shell 配置(默认 true)。
382
+ * su -c 创建非交互式 shell,不会自动执行 rc 文件,
383
+ * 但大多数用户的环境变量设置在 rc 文件中,因此默认加载。
384
+ * 支持 bash(.bashrc)、zsh(.zshrc) 及其他 shell(.profile)。
385
+ */
386
+ async execAsUser(
387
+ alias: string,
388
+ command: string,
389
+ targetUser: string,
390
+ options: ExecOptions & { loadProfile?: boolean } = {},
391
+ ): Promise<ExecResult> {
392
+ // 校验用户名防止注入
393
+ if (!this.isValidUsername(targetUser)) {
394
+ throw new Error(`Invalid username: ${targetUser}`)
395
+ }
396
+
397
+ const {loadProfile = true, ...execOpts} = options
398
+
399
+ const wrappedCommand = loadProfile
400
+ ? `${this.getLoadProfileCommand()}${command}`
401
+ : command
402
+
403
+ const suCommand = `su - ${targetUser} -c ${this.escapeShellArg(wrappedCommand)}`
404
+ return this.exec(alias, suCommand, execOpts)
405
+ }
406
+
407
+ /**
408
+ * 使用 sudo 执行命令
409
+ */
410
+ async execSudo(
411
+ alias: string,
412
+ command: string,
413
+ sudoPassword?: string,
414
+ options: ExecOptions = {},
415
+ ): Promise<ExecResult> {
416
+ let sudoCommand: string
417
+ if (sudoPassword) {
418
+ // 通过 stdin 传递密码(使用 escapeShellArg 转义)
419
+ sudoCommand = `echo ${this.escapeShellArg(sudoPassword)} | sudo -S ${command}`
420
+ } else {
421
+ sudoCommand = `sudo ${command}`
422
+ }
423
+ return this.exec(alias, sudoCommand, options)
424
+ }
425
+
426
+ /**
427
+ * 获取 SFTP 客户端
428
+ */
429
+ getSftp(alias: string): Promise<SFTPWrapper> {
430
+ const session = this.getSession(alias)
431
+ return new Promise((resolve, reject) => {
432
+ session.client.sftp((err, sftp) => {
433
+ if (err) {
434
+ reject(err)
435
+ } else {
436
+ resolve(sftp)
437
+ }
438
+ })
439
+ })
440
+ }
441
+
442
+ /**
443
+ * 加载持久化的会话信息(仅用于显示,不自动重连)
444
+ */
445
+ loadPersistedSessions(): PersistedSession[] {
446
+ try {
447
+ if (fs.existsSync(this.persistPath)) {
448
+ return JSON.parse(fs.readFileSync(this.persistPath, 'utf-8'))
606
449
  }
607
- });
608
-
609
- stream.on('close', () => {
610
- ptySession.active = false;
611
- });
612
-
613
- stream.on('error', (err: Error) => {
614
- ptySession.active = false;
615
- terminal.write(`\n[PTY Error: ${err.message}]`);
616
- });
617
-
618
- this.ptySessions.set(ptyId, ptySession);
619
- resolve(ptyId);
620
- }
621
- );
622
- });
623
- }
624
-
625
- /**
626
- * PTY 写入数据
627
- */
628
- ptyWrite(ptyId: string, data: string): boolean {
629
- const ptySession = this.ptySessions.get(ptyId);
630
- if (!ptySession) {
631
- throw new Error(`PTY session '${ptyId}' not found`);
632
- }
633
- if (!ptySession.active) {
634
- throw new Error(`PTY session '${ptyId}' is closed`);
635
- }
636
- return ptySession.stream.write(data);
637
- }
638
-
639
- /**
640
- * 从终端仿真器获取当前屏幕内容
641
- */
642
- private getScreenContent(terminal: TerminalType): string {
643
- const buffer = terminal.buffer.active;
644
- const lines: string[] = [];
645
- for (let i = 0; i < terminal.rows; i++) {
646
- const line = buffer.getLine(i);
647
- if (line) {
648
- lines.push(line.translateToString(true));
649
- }
650
- }
651
- // 移除尾部空行
652
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
653
- lines.pop();
654
- }
655
- return lines.join('\n');
656
- }
657
-
658
- /**
659
- * 读取 PTY 输出
660
- * @param mode 'screen' 返回当前屏幕内容,'raw' 返回原始 ANSI 流
661
- */
662
- ptyRead(
663
- ptyId: string,
664
- options: { mode?: 'screen' | 'raw'; clear?: boolean } = {}
665
- ): { data: string; active: boolean; rows: number; cols: number } {
666
- const ptySession = this.ptySessions.get(ptyId);
667
- if (!ptySession) {
668
- throw new Error(`PTY session '${ptyId}' not found`);
669
- }
670
-
671
- const mode = options.mode || 'screen';
672
- const clear = options.clear !== false;
673
-
674
- let data: string;
675
- if (mode === 'screen') {
676
- // 返回当前屏幕内容(解析后的纯文本)
677
- data = this.getScreenContent(ptySession.terminal);
678
- } else {
679
- // 返回原始 ANSI 流
680
- data = ptySession.rawBuffer;
681
- if (clear) {
682
- ptySession.rawBuffer = '';
683
- }
684
- }
685
-
686
- ptySession.lastReadAt = Date.now();
687
- return {
688
- data,
689
- active: ptySession.active,
690
- rows: ptySession.rows,
691
- cols: ptySession.cols,
692
- };
693
- }
694
-
695
- /**
696
- * 调整 PTY 窗口大小
697
- */
698
- ptyResize(ptyId: string, rows: number, cols: number): boolean {
699
- const ptySession = this.ptySessions.get(ptyId);
700
- if (!ptySession) {
701
- throw new Error(`PTY session '${ptyId}' not found`);
702
- }
703
- if (!ptySession.active) {
704
- throw new Error(`PTY session '${ptyId}' is closed`);
705
- }
706
- // 调整远程 PTY 窗口
707
- ptySession.stream.setWindow(rows, cols, 0, 0);
708
- // 调整本地终端仿真器
709
- ptySession.terminal.resize(cols, rows);
710
- ptySession.rows = rows;
711
- ptySession.cols = cols;
712
- return true;
713
- }
714
-
715
- /**
716
- * 关闭 PTY 会话
717
- */
718
- ptyClose(ptyId: string): boolean {
719
- const ptySession = this.ptySessions.get(ptyId);
720
- if (!ptySession) {
721
- return false;
722
- }
723
- try {
724
- ptySession.stream.close();
725
- } catch {}
726
- try {
727
- ptySession.terminal.dispose();
728
- } catch {}
729
- ptySession.active = false;
730
- this.ptySessions.delete(ptyId);
731
- return true;
732
- }
733
-
734
- /**
735
- * 列出所有 PTY 会话
736
- */
737
- ptyList(): PtySessionInfo[] {
738
- const result: PtySessionInfo[] = [];
739
- for (const [id, pty] of this.ptySessions) {
740
- result.push({
741
- id,
742
- alias: pty.alias,
743
- command: pty.command,
744
- rows: pty.rows,
745
- cols: pty.cols,
746
- createdAt: pty.createdAt,
747
- lastReadAt: pty.lastReadAt,
748
- bufferSize: pty.rawBuffer.length,
749
- active: pty.active,
750
- });
751
- }
752
- return result;
753
- }
754
-
755
- /**
756
- * 关闭所有 PTY 会话
757
- */
758
- ptyCloseAll(): number {
759
- let count = 0;
760
- for (const ptyId of this.ptySessions.keys()) {
761
- if (this.ptyClose(ptyId)) {
762
- count++;
763
- }
764
- }
765
- return count;
766
- }
767
-
768
- // ========== 端口转发 ==========
769
-
770
- /**
771
- * 生成端口转发 ID
772
- */
773
- private generateForwardId(): string {
774
- return `fwd_${++this.forwardIdCounter}_${Date.now()}`;
775
- }
776
-
777
- /**
778
- * 创建本地端口转发
779
- * 本地监听 localHost:localPort,转发到远程 remoteHost:remotePort
780
- */
781
- async forwardLocal(
782
- alias: string,
783
- localPort: number,
784
- remoteHost: string,
785
- remotePort: number,
786
- localHost: string = '127.0.0.1'
787
- ): Promise<string> {
788
- const session = this.getSession(alias);
789
- const forwardId = this.generateForwardId();
790
-
791
- return new Promise((resolve, reject) => {
792
- const server = net.createServer((socket) => {
793
- session.client.forwardOut(
794
- socket.remoteAddress || '127.0.0.1',
795
- socket.remotePort || 0,
796
- remoteHost,
797
- remotePort,
798
- (err, stream) => {
799
- if (err) {
800
- socket.end();
801
- return;
450
+ } catch {
451
+ }
452
+ return []
453
+ }
454
+
455
+ /**
456
+ * 启动持久化 PTY 会话
457
+ */
458
+ async ptyStart(
459
+ alias: string,
460
+ command: string,
461
+ options: PtyOptions = {},
462
+ ): Promise<string> {
463
+ const session = this.getSession(alias)
464
+ const ptyId = this.generatePtyId()
465
+
466
+ const rows = options.rows || 24
467
+ const cols = options.cols || 80
468
+ const term = options.term || 'xterm-256color'
469
+ const maxBufferSize = options.bufferSize || this.defaultPtyBufferSize
470
+
471
+ // 构建完整命令
472
+ let fullCommand = command
473
+ const env = {...session.config.env, ...options.env}
474
+
475
+ if (Object.keys(env).length > 0) {
476
+ const envStr = Object.entries(env)
477
+ .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
478
+ .join('; ')
479
+ fullCommand = `${envStr}; ${command}`
480
+ }
481
+
482
+ if (options.cwd) {
483
+ fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`
484
+ }
485
+
486
+ return new Promise((resolve, reject) => {
487
+ session.client.exec(
488
+ fullCommand,
489
+ {
490
+ pty: {rows, cols, term},
491
+ },
492
+ (err, stream) => {
493
+ if (err) {
494
+ reject(new Error(`PTY start failed: ${err.message}`))
495
+ return
496
+ }
497
+
498
+ // 创建 xterm headless 终端仿真器
499
+ const terminal = new Terminal({
500
+ rows,
501
+ cols,
502
+ allowProposedApi: true,
503
+ })
504
+
505
+ const ptySession: PtySession = {
506
+ id: ptyId,
507
+ alias,
508
+ command,
509
+ stream,
510
+ terminal,
511
+ rows,
512
+ cols,
513
+ createdAt: Date.now(),
514
+ lastReadAt: Date.now(),
515
+ rawBuffer: '',
516
+ maxBufferSize,
517
+ active: true,
518
+ }
519
+
520
+ // 监听输出数据
521
+ stream.on('data', (data: Buffer) => {
522
+ if (!ptySession.active) {
523
+ return
524
+ }
525
+ const chunk = data.toString('utf-8')
526
+ // 写入终端仿真器(解析 ANSI 序列)
527
+ terminal.write(chunk)
528
+ // 同时保留原始流(用于 raw 模式)
529
+ ptySession.rawBuffer += chunk
530
+ if (ptySession.rawBuffer.length > maxBufferSize) {
531
+ ptySession.rawBuffer = ptySession.rawBuffer.slice(-maxBufferSize)
532
+ }
533
+ })
534
+
535
+ stream.on('close', () => {
536
+ ptySession.active = false
537
+ })
538
+
539
+ stream.on('error', (err: Error) => {
540
+ ptySession.active = false
541
+ terminal.write(`\n[PTY Error: ${err.message}]`)
542
+ })
543
+
544
+ this.ptySessions.set(ptyId, ptySession)
545
+ resolve(ptyId)
546
+ },
547
+ )
548
+ })
549
+ }
550
+
551
+ /**
552
+ * 向 PTY 写入数据
553
+ */
554
+ ptyWrite(ptyId: string, data: string): boolean {
555
+ const ptySession = this.ptySessions.get(ptyId)
556
+ if (!ptySession) {
557
+ throw new Error(`PTY session '${ptyId}' not found`)
558
+ }
559
+ if (!ptySession.active) {
560
+ throw new Error(`PTY session '${ptyId}' is closed`)
561
+ }
562
+ return ptySession.stream.write(data)
563
+ }
564
+
565
+ /**
566
+ * 读取 PTY 输出
567
+ * @param mode 'screen' 返回当前屏幕内容,'raw' 返回原始 ANSI 流
568
+ */
569
+ ptyRead(
570
+ ptyId: string,
571
+ options: { mode?: 'screen' | 'raw'; clear?: boolean } = {},
572
+ ): { data: string; active: boolean; rows: number; cols: number } {
573
+ const ptySession = this.ptySessions.get(ptyId)
574
+ if (!ptySession) {
575
+ throw new Error(`PTY session '${ptyId}' not found`)
576
+ }
577
+
578
+ const mode = options.mode || 'screen'
579
+ const clear = options.clear !== false
580
+
581
+ let data: string
582
+ if (mode === 'screen') {
583
+ // 返回当前屏幕内容(解析后的纯文本)
584
+ data = this.getScreenContent(ptySession.terminal)
585
+ } else {
586
+ // 返回原始 ANSI 流
587
+ data = ptySession.rawBuffer
588
+ if (clear) {
589
+ ptySession.rawBuffer = ''
802
590
  }
803
- socket.pipe(stream).pipe(socket);
804
- }
805
- );
806
- });
807
-
808
- server.on('error', (err) => {
809
- reject(new Error(`Local forward failed: ${err.message}`));
810
- });
811
-
812
- server.listen(localPort, localHost, () => {
813
- const fwdSession: ForwardSession = {
814
- id: forwardId,
815
- alias,
816
- type: 'local',
817
- localHost,
818
- localPort,
819
- remoteHost,
820
- remotePort,
821
- server,
822
- createdAt: Date.now(),
823
- active: true,
824
- };
825
- this.forwardSessions.set(forwardId, fwdSession);
826
- resolve(forwardId);
827
- });
828
- });
829
- }
830
-
831
- /**
832
- * 创建远程端口转发
833
- * 远程监听 remoteHost:remotePort,转发到本地 localHost:localPort
834
- */
835
- async forwardRemote(
836
- alias: string,
837
- remotePort: number,
838
- localHost: string,
839
- localPort: number,
840
- remoteHost: string = '127.0.0.1'
841
- ): Promise<string> {
842
- const session = this.getSession(alias);
843
- const forwardId = this.generateForwardId();
844
-
845
- return new Promise((resolve, reject) => {
846
- session.client.forwardIn(remoteHost, remotePort, (err) => {
847
- if (err) {
848
- reject(new Error(`Remote forward failed: ${err.message}`));
849
- return;
850
- }
851
-
852
- const fwdSession: ForwardSession = {
853
- id: forwardId,
854
- alias,
855
- type: 'remote',
856
- localHost,
857
- localPort,
858
- remoteHost,
859
- remotePort,
860
- createdAt: Date.now(),
861
- active: true,
862
- };
863
- this.forwardSessions.set(forwardId, fwdSession);
864
-
865
- // 确保该 session 有共享的 tcp dispatcher
866
- this.ensureTcpDispatcher(session, alias);
867
-
868
- resolve(forwardId);
869
- });
870
- });
871
- }
872
-
873
- /**
874
- * 确保 SSH session 有共享的 tcp connection dispatcher
875
- * 所有 remote forward 共用一个 dispatcher,根据 destIP/destPort 路由
876
- * @param alias - session 的 map key
877
- */
878
- private ensureTcpDispatcher(session: SSHSession, alias: string): void {
879
- if (session.tcpDispatcher) {
880
- return; // 已存在
881
- }
882
-
883
- const dispatcher: TcpConnectionHandler = (info, accept, rejectConn) => {
884
- // 查找匹配的 remote forward
885
- for (const fwd of this.forwardSessions.values()) {
886
- if (fwd.type === 'remote' &&
887
- fwd.active &&
888
- fwd.alias === alias &&
889
- fwd.remoteHost === info.destIP &&
890
- fwd.remotePort === info.destPort) {
891
- // 找到匹配的 forward,建立连接
892
- const stream = accept();
893
- const socket = net.createConnection(fwd.localPort, fwd.localHost);
894
- socket.pipe(stream).pipe(socket);
895
- socket.on('error', () => stream.close());
896
- stream.on('error', () => socket.destroy());
897
- return;
898
- }
899
- }
900
- // 没有匹配的 forward,拒绝连接
901
- rejectConn();
902
- };
903
-
904
- session.tcpDispatcher = dispatcher;
905
- session.client.on('tcp connection', dispatcher);
906
- }
907
-
908
- /**
909
- * 移除 SSH session 的 tcp dispatcher(当没有 remote forward 时)
910
- * @param alias - session 的 map key
911
- */
912
- private removeTcpDispatcherIfEmpty(session: SSHSession, alias: string): void {
913
- if (!session.tcpDispatcher) {
914
- return;
915
- }
916
-
917
- // 检查是否还有该 session 的 remote forward
918
- for (const fwd of this.forwardSessions.values()) {
919
- if (fwd.type === 'remote' && fwd.alias === alias && fwd.active) {
920
- return; // 还有活跃的 remote forward
921
- }
922
- }
923
-
924
- // 没有了,移除 dispatcher
925
- session.client.removeListener('tcp connection', session.tcpDispatcher);
926
- session.tcpDispatcher = undefined;
927
- }
928
-
929
- /**
930
- * 关闭端口转发
931
- */
932
- forwardClose(forwardId: string): boolean {
933
- const fwdSession = this.forwardSessions.get(forwardId);
934
- if (!fwdSession) {
935
- return false;
936
- }
937
-
938
- fwdSession.active = false;
939
-
940
- if (fwdSession.type === 'local' && fwdSession.server) {
941
- try {
942
- fwdSession.server.close();
943
- } catch {}
944
- } else if (fwdSession.type === 'remote') {
945
- const session = this.sessions.get(fwdSession.alias);
946
- if (session) {
591
+ }
592
+
593
+ ptySession.lastReadAt = Date.now()
594
+ return {
595
+ data,
596
+ active: ptySession.active,
597
+ rows: ptySession.rows,
598
+ cols: ptySession.cols,
599
+ }
600
+ }
601
+
602
+ /**
603
+ * 调整 PTY 窗口大小
604
+ */
605
+ ptyResize(ptyId: string, rows: number, cols: number): boolean {
606
+ const ptySession = this.ptySessions.get(ptyId)
607
+ if (!ptySession) {
608
+ throw new Error(`PTY session '${ptyId}' not found`)
609
+ }
610
+ if (!ptySession.active) {
611
+ throw new Error(`PTY session '${ptyId}' is closed`)
612
+ }
613
+ // 调整远程 PTY 窗口
614
+ ptySession.stream.setWindow(rows, cols, 0, 0)
615
+ // 调整本地终端仿真器
616
+ ptySession.terminal.resize(cols, rows)
617
+ ptySession.rows = rows
618
+ ptySession.cols = cols
619
+ return true
620
+ }
621
+
622
+ /**
623
+ * 关闭 PTY 会话
624
+ */
625
+ ptyClose(ptyId: string): boolean {
626
+ const ptySession = this.ptySessions.get(ptyId)
627
+ if (!ptySession) {
628
+ return false
629
+ }
630
+ try {
631
+ ptySession.stream.close()
632
+ } catch {
633
+ }
634
+ try {
635
+ ptySession.terminal.dispose()
636
+ } catch {
637
+ }
638
+ ptySession.active = false
639
+ this.ptySessions.delete(ptyId)
640
+ return true
641
+ }
642
+
643
+ /**
644
+ * 列出所有 PTY 会话
645
+ */
646
+ ptyList(): PtySessionInfo[] {
647
+ const result: PtySessionInfo[] = []
648
+ for (const [id, pty] of this.ptySessions) {
649
+ result.push({
650
+ id,
651
+ alias: pty.alias,
652
+ command: pty.command,
653
+ rows: pty.rows,
654
+ cols: pty.cols,
655
+ createdAt: pty.createdAt,
656
+ lastReadAt: pty.lastReadAt,
657
+ bufferSize: pty.rawBuffer.length,
658
+ active: pty.active,
659
+ })
660
+ }
661
+ return result
662
+ }
663
+
664
+ /**
665
+ * 关闭所有 PTY 会话
666
+ */
667
+ ptyCloseAll(): number {
668
+ let count = 0
669
+ for (const ptyId of this.ptySessions.keys()) {
670
+ if (this.ptyClose(ptyId)) {
671
+ count++
672
+ }
673
+ }
674
+ return count
675
+ }
676
+
677
+ /**
678
+ * 创建本地端口转发
679
+ * 本地监听 localHost:localPort,转发到远程 remoteHost:remotePort
680
+ */
681
+ async forwardLocal(
682
+ alias: string,
683
+ localPort: number,
684
+ remoteHost: string,
685
+ remotePort: number,
686
+ localHost: string = '127.0.0.1',
687
+ ): Promise<string> {
688
+ const session = this.getSession(alias)
689
+ const forwardId = this.generateForwardId()
690
+
691
+ return new Promise((resolve, reject) => {
692
+ const server = net.createServer((socket) => {
693
+ session.client.forwardOut(
694
+ socket.remoteAddress || '127.0.0.1',
695
+ socket.remotePort || 0,
696
+ remoteHost,
697
+ remotePort,
698
+ (err, stream) => {
699
+ if (err) {
700
+ socket.end()
701
+ return
702
+ }
703
+ socket.pipe(stream).pipe(socket)
704
+ },
705
+ )
706
+ })
707
+
708
+ server.on('error', (err) => {
709
+ reject(new Error(`Local forward failed: ${err.message}`))
710
+ })
711
+
712
+ server.listen(localPort, localHost, () => {
713
+ const fwdSession: ForwardSession = {
714
+ id: forwardId,
715
+ alias,
716
+ type: 'local',
717
+ localHost,
718
+ localPort,
719
+ remoteHost,
720
+ remotePort,
721
+ server,
722
+ createdAt: Date.now(),
723
+ active: true,
724
+ }
725
+ this.forwardSessions.set(forwardId, fwdSession)
726
+ resolve(forwardId)
727
+ })
728
+ })
729
+ }
730
+
731
+ /**
732
+ * 创建远程端口转发
733
+ * 远程监听 remoteHost:remotePort,转发到本地 localHost:localPort
734
+ */
735
+ async forwardRemote(
736
+ alias: string,
737
+ remotePort: number,
738
+ localHost: string,
739
+ localPort: number,
740
+ remoteHost: string = '127.0.0.1',
741
+ ): Promise<string> {
742
+ const session = this.getSession(alias)
743
+ const forwardId = this.generateForwardId()
744
+
745
+ return new Promise((resolve, reject) => {
746
+ session.client.forwardIn(remoteHost, remotePort, (err) => {
747
+ if (err) {
748
+ reject(new Error(`Remote forward failed: ${err.message}`))
749
+ return
750
+ }
751
+
752
+ const fwdSession: ForwardSession = {
753
+ id: forwardId,
754
+ alias,
755
+ type: 'remote',
756
+ localHost,
757
+ localPort,
758
+ remoteHost,
759
+ remotePort,
760
+ createdAt: Date.now(),
761
+ active: true,
762
+ }
763
+ this.forwardSessions.set(forwardId, fwdSession)
764
+
765
+ // 确保该 session 有共享的 tcp dispatcher
766
+ this.ensureTcpDispatcher(session, alias)
767
+
768
+ resolve(forwardId)
769
+ })
770
+ })
771
+ }
772
+
773
+ // ========== PTY 会话管理 ==========
774
+
775
+ /**
776
+ * 关闭端口转发
777
+ */
778
+ forwardClose(forwardId: string): boolean {
779
+ const fwdSession = this.forwardSessions.get(forwardId)
780
+ if (!fwdSession) {
781
+ return false
782
+ }
783
+
784
+ fwdSession.active = false
785
+
786
+ if (fwdSession.type === 'local' && fwdSession.server) {
787
+ try {
788
+ fwdSession.server.close()
789
+ } catch {
790
+ }
791
+ } else if (fwdSession.type === 'remote') {
792
+ const session = this.sessions.get(fwdSession.alias)
793
+ if (session) {
794
+ try {
795
+ session.client.unforwardIn(fwdSession.remoteHost, fwdSession.remotePort)
796
+ } catch {
797
+ }
798
+ // 检查是否需要移除共享 dispatcher
799
+ this.removeTcpDispatcherIfEmpty(session, fwdSession.alias)
800
+ }
801
+ }
802
+
803
+ this.forwardSessions.delete(forwardId)
804
+ return true
805
+ }
806
+
807
+ /**
808
+ * 列出所有端口转发
809
+ */
810
+ forwardList(): PortForwardInfo[] {
811
+ const result: PortForwardInfo[] = []
812
+ for (const [id, fwd] of this.forwardSessions) {
813
+ result.push({
814
+ id,
815
+ alias: fwd.alias,
816
+ type: fwd.type,
817
+ localHost: fwd.localHost,
818
+ localPort: fwd.localPort,
819
+ remoteHost: fwd.remoteHost,
820
+ remotePort: fwd.remotePort,
821
+ createdAt: fwd.createdAt,
822
+ active: fwd.active,
823
+ })
824
+ }
825
+ return result
826
+ }
827
+
828
+ private ensurePersistDir(): void {
829
+ const dir = path.dirname(this.persistPath)
830
+ if (!fs.existsSync(dir)) {
831
+ fs.mkdirSync(dir, {recursive: true})
832
+ }
833
+ }
834
+
835
+ /**
836
+ * 生成连接别名
837
+ */
838
+ private generateAlias(config: SSHConnectionConfig): string {
839
+ return config.alias || `${config.username}@${config.host}:${config.port}`
840
+ }
841
+
842
+ /**
843
+ * 通过跳板机转发连接
844
+ */
845
+ private forwardConnection(
846
+ jumpClient: Client,
847
+ targetHost: string,
848
+ targetPort: number,
849
+ ): Promise<ClientChannel> {
850
+ return new Promise((resolve, reject) => {
851
+ jumpClient.forwardOut(
852
+ '127.0.0.1',
853
+ 0,
854
+ targetHost,
855
+ targetPort,
856
+ (err, stream) => {
857
+ if (err) {
858
+ reject(err)
859
+ } else {
860
+ resolve(stream)
861
+ }
862
+ },
863
+ )
864
+ })
865
+ }
866
+
867
+ /**
868
+ * 检查连接是否存活
869
+ */
870
+ private isAlive(session: SSHSession): boolean {
871
+ return session.connected
872
+ }
873
+
874
+ /**
875
+ * 转义 shell 参数(使用单引号方式)
876
+ */
877
+ private escapeShellArg(s: string): string {
878
+ return `'${s.replace(/'/g, '\'\\\'\'')}'`
879
+ }
880
+
881
+ /**
882
+ * 校验用户名(只允许字母、数字、下划线、连字符)
883
+ */
884
+ private isValidUsername(username: string): boolean {
885
+ return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(username)
886
+ }
887
+
888
+ /**
889
+ * 校验环境变量名(只允许字母、数字、下划线,不能以数字开头)
890
+ */
891
+ private isValidEnvKey(key: string): boolean {
892
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
893
+ }
894
+
895
+ // ========== 端口转发 ==========
896
+
897
+ /**
898
+ * 根据用户 shell 类型生成加载配置文件的命令
899
+ * bash → .bashrc, zsh → .zshrc, 其他 → .profile
900
+ */
901
+ private getLoadProfileCommand(): string {
902
+ return 'case "$(basename "$SHELL" 2>/dev/null)" in ' +
903
+ 'bash) [ -f ~/.bashrc ] && . ~/.bashrc ;; ' +
904
+ 'zsh) [ -f ~/.zshrc ] && . ~/.zshrc ;; ' +
905
+ '*) [ -f ~/.profile ] && . ~/.profile ;; ' +
906
+ 'esac 2>/dev/null; '
907
+ }
908
+
909
+ /**
910
+ * 持久化会话信息
911
+ */
912
+ private persistSessions(): void {
913
+ const data: PersistedSession[] = []
914
+ for (const [alias, session] of this.sessions) {
915
+ // 不保存敏感信息(密码、密钥)
916
+ data.push({
917
+ alias,
918
+ host: session.config.host,
919
+ port: session.config.port || 22,
920
+ username: session.config.username,
921
+ connectedAt: session.connectedAt,
922
+ env: session.config.env,
923
+ })
924
+ }
947
925
  try {
948
- session.client.unforwardIn(fwdSession.remoteHost, fwdSession.remotePort);
949
- } catch {}
950
- // 检查是否需要移除共享 dispatcher
951
- this.removeTcpDispatcherIfEmpty(session, fwdSession.alias);
952
- }
953
- }
954
-
955
- this.forwardSessions.delete(forwardId);
956
- return true;
957
- }
958
-
959
- /**
960
- * 列出所有端口转发
961
- */
962
- forwardList(): PortForwardInfo[] {
963
- const result: PortForwardInfo[] = [];
964
- for (const [id, fwd] of this.forwardSessions) {
965
- result.push({
966
- id,
967
- alias: fwd.alias,
968
- type: fwd.type,
969
- localHost: fwd.localHost,
970
- localPort: fwd.localPort,
971
- remoteHost: fwd.remoteHost,
972
- remotePort: fwd.remotePort,
973
- createdAt: fwd.createdAt,
974
- active: fwd.active,
975
- });
976
- }
977
- return result;
978
- }
926
+ fs.writeFileSync(this.persistPath, JSON.stringify(data, null, 2))
927
+ } catch (e) {
928
+ // 忽略写入错误
929
+ }
930
+ }
931
+
932
+ /**
933
+ * 生成 PTY 会话 ID
934
+ */
935
+ private generatePtyId(): string {
936
+ return `pty_${++this.ptyIdCounter}_${Date.now()}`
937
+ }
938
+
939
+ /**
940
+ * 从终端仿真器获取当前屏幕内容
941
+ */
942
+ private getScreenContent(terminal: TerminalType): string {
943
+ const buffer = terminal.buffer.active
944
+ const lines: string[] = []
945
+ for (let i = 0; i < terminal.rows; i++) {
946
+ const line = buffer.getLine(i)
947
+ if (line) {
948
+ lines.push(line.translateToString(true))
949
+ }
950
+ }
951
+ // 移除尾部空行
952
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
953
+ lines.pop()
954
+ }
955
+ return lines.join('\n')
956
+ }
957
+
958
+ /**
959
+ * 生成端口转发 ID
960
+ */
961
+ private generateForwardId(): string {
962
+ return `fwd_${++this.forwardIdCounter}_${Date.now()}`
963
+ }
964
+
965
+ /**
966
+ * 确保 SSH session 有共享的 tcp connection dispatcher
967
+ * 所有 remote forward 共用一个 dispatcher,根据 destIP/destPort 路由
968
+ * @param alias - session 的 map key
969
+ */
970
+ private ensureTcpDispatcher(session: SSHSession, alias: string): void {
971
+ if (session.tcpDispatcher) {
972
+ return // 已存在
973
+ }
974
+
975
+ const dispatcher: TcpConnectionHandler = (info, accept, rejectConn) => {
976
+ // 查找匹配的 remote forward
977
+ for (const fwd of this.forwardSessions.values()) {
978
+ if (fwd.type === 'remote' &&
979
+ fwd.active &&
980
+ fwd.alias === alias &&
981
+ fwd.remoteHost === info.destIP &&
982
+ fwd.remotePort === info.destPort) {
983
+ // 找到匹配的 forward,建立连接
984
+ const stream = accept()
985
+ const socket = net.createConnection(fwd.localPort, fwd.localHost)
986
+ socket.pipe(stream).pipe(socket)
987
+ socket.on('error', () => stream.close())
988
+ stream.on('error', () => socket.destroy())
989
+ return
990
+ }
991
+ }
992
+ // 没有匹配的 forward,拒绝连接
993
+ rejectConn()
994
+ }
995
+
996
+ session.tcpDispatcher = dispatcher
997
+ session.client.on('tcp connection', dispatcher)
998
+ }
999
+
1000
+ /**
1001
+ * 移除 SSH session 的 tcp dispatcher(当没有 remote forward 时)
1002
+ * @param alias - session 的 map key
1003
+ */
1004
+ private removeTcpDispatcherIfEmpty(session: SSHSession, alias: string): void {
1005
+ if (!session.tcpDispatcher) {
1006
+ return
1007
+ }
1008
+
1009
+ // 检查是否还有该 session 的 remote forward
1010
+ for (const fwd of this.forwardSessions.values()) {
1011
+ if (fwd.type === 'remote' && fwd.alias === alias && fwd.active) {
1012
+ return // 还有活跃的 remote forward
1013
+ }
1014
+ }
1015
+
1016
+ // 没有了,移除 dispatcher
1017
+ session.client.removeListener('tcp connection', session.tcpDispatcher)
1018
+ session.tcpDispatcher = undefined
1019
+ }
979
1020
  }
980
1021
 
981
1022
  // 全局单例
982
- export const sessionManager = new SessionManager();
1023
+ export const sessionManager = new SessionManager()