@pyrokine/mcp-ssh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,792 @@
1
+ /**
2
+ * SSH Session Manager - 连接池管理
3
+ *
4
+ * 功能:
5
+ * - 连接池复用
6
+ * - 心跳保持
7
+ * - 自动重连
8
+ * - 会话持久化
9
+ */
10
+ import { Client } from 'ssh2';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import xterm from '@xterm/headless';
14
+ const Terminal = xterm.Terminal;
15
+ import * as net from 'net';
16
+ export class SessionManager {
17
+ sessions = new Map();
18
+ ptySessions = new Map();
19
+ forwardSessions = new Map();
20
+ ptyIdCounter = 0;
21
+ forwardIdCounter = 0;
22
+ persistPath;
23
+ defaultKeepaliveInterval = 30000; // 30秒
24
+ defaultKeepaliveCountMax = 3;
25
+ defaultTimeout = 30000; // 30秒
26
+ maxReconnectAttempts = 3;
27
+ defaultPtyBufferSize = 1024 * 1024; // 1MB
28
+ constructor(persistPath) {
29
+ this.persistPath = persistPath || path.join(process.env.HOME || '/tmp', '.ssh-mcp-pro', 'sessions.json');
30
+ this.ensurePersistDir();
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
+ /**
45
+ * 建立 SSH 连接
46
+ */
47
+ async connect(config) {
48
+ const alias = this.generateAlias(config);
49
+ // 检查是否已有活跃连接
50
+ const existing = this.sessions.get(alias);
51
+ if (existing && this.isAlive(existing)) {
52
+ existing.lastUsedAt = Date.now();
53
+ return alias;
54
+ }
55
+ const client = new Client();
56
+ // 构建连接配置
57
+ const connectConfig = {
58
+ host: config.host,
59
+ port: config.port || 22,
60
+ username: config.username,
61
+ readyTimeout: config.readyTimeout || this.defaultTimeout,
62
+ keepaliveInterval: config.keepaliveInterval || this.defaultKeepaliveInterval,
63
+ keepaliveCountMax: config.keepaliveCountMax || this.defaultKeepaliveCountMax,
64
+ };
65
+ // 认证方式
66
+ if (config.password) {
67
+ connectConfig.password = config.password;
68
+ }
69
+ if (config.privateKeyPath) {
70
+ connectConfig.privateKey = fs.readFileSync(config.privateKeyPath);
71
+ }
72
+ if (config.privateKey) {
73
+ connectConfig.privateKey = config.privateKey;
74
+ }
75
+ if (config.passphrase) {
76
+ connectConfig.passphrase = config.passphrase;
77
+ }
78
+ // 跳板机支持
79
+ if (config.jumpHost) {
80
+ const jumpAlias = await this.connect(config.jumpHost);
81
+ const jumpSession = this.sessions.get(jumpAlias);
82
+ if (jumpSession) {
83
+ // 通过跳板机建立连接
84
+ const stream = await this.forwardConnection(jumpSession.client, config.host, config.port || 22);
85
+ connectConfig.sock = stream;
86
+ }
87
+ }
88
+ return new Promise((resolve, reject) => {
89
+ client.on('ready', () => {
90
+ const session = {
91
+ client,
92
+ config,
93
+ connectedAt: Date.now(),
94
+ lastUsedAt: Date.now(),
95
+ reconnectAttempts: 0,
96
+ connected: true,
97
+ };
98
+ this.sessions.set(alias, session);
99
+ this.persistSessions();
100
+ resolve(alias);
101
+ });
102
+ client.on('error', (err) => {
103
+ const session = this.sessions.get(alias);
104
+ if (session) {
105
+ session.connected = false;
106
+ }
107
+ reject(new Error(`SSH connection failed: ${err.message}`));
108
+ });
109
+ client.on('close', () => {
110
+ const session = this.sessions.get(alias);
111
+ if (session) {
112
+ session.connected = false;
113
+ // 自动重连逻辑
114
+ if (session.reconnectAttempts < this.maxReconnectAttempts) {
115
+ session.reconnectAttempts++;
116
+ setTimeout(() => {
117
+ this.reconnect(alias).catch(() => { });
118
+ }, 5000); // 5 秒后重连
119
+ }
120
+ }
121
+ });
122
+ client.connect(connectConfig);
123
+ });
124
+ }
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
+ /**
145
+ * 重新连接
146
+ */
147
+ async reconnect(alias) {
148
+ const session = this.sessions.get(alias);
149
+ if (!session) {
150
+ throw new Error(`Session ${alias} not found`);
151
+ }
152
+ try {
153
+ session.client.end();
154
+ }
155
+ catch { }
156
+ await this.connect(session.config);
157
+ }
158
+ /**
159
+ * 断开连接
160
+ */
161
+ disconnect(alias) {
162
+ const session = this.sessions.get(alias);
163
+ if (session) {
164
+ try {
165
+ session.client.end();
166
+ }
167
+ catch { }
168
+ this.sessions.delete(alias);
169
+ this.persistSessions();
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+ /**
175
+ * 断开所有连接
176
+ */
177
+ disconnectAll() {
178
+ for (const alias of this.sessions.keys()) {
179
+ this.disconnect(alias);
180
+ }
181
+ }
182
+ /**
183
+ * 获取会话
184
+ */
185
+ getSession(alias) {
186
+ const session = this.sessions.get(alias);
187
+ if (!session) {
188
+ throw new Error(`Session '${alias}' not found. Use ssh_connect first.`);
189
+ }
190
+ if (!this.isAlive(session)) {
191
+ throw new Error(`Session '${alias}' is disconnected. Use ssh_connect to reconnect.`);
192
+ }
193
+ session.lastUsedAt = Date.now();
194
+ return session;
195
+ }
196
+ /**
197
+ * 列出所有会话
198
+ */
199
+ listSessions() {
200
+ const result = [];
201
+ for (const [alias, session] of this.sessions) {
202
+ result.push({
203
+ alias,
204
+ host: session.config.host,
205
+ port: session.config.port || 22,
206
+ username: session.config.username,
207
+ connected: this.isAlive(session),
208
+ connectedAt: session.connectedAt,
209
+ lastUsedAt: session.lastUsedAt,
210
+ env: session.config.env,
211
+ });
212
+ }
213
+ return result;
214
+ }
215
+ /**
216
+ * 转义 shell 参数(使用单引号方式)
217
+ */
218
+ escapeShellArg(s) {
219
+ return `'${s.replace(/'/g, "'\\''")}'`;
220
+ }
221
+ /**
222
+ * 执行命令
223
+ */
224
+ async exec(alias, command, options = {}) {
225
+ const session = this.getSession(alias);
226
+ const startTime = Date.now();
227
+ const maxOutputSize = options.maxOutputSize || 10 * 1024 * 1024; // 默认 10MB
228
+ // 构建完整命令(包含环境变量)
229
+ let fullCommand = command;
230
+ const env = { ...session.config.env, ...options.env };
231
+ if (Object.keys(env).length > 0) {
232
+ // 校验并过滤环境变量名
233
+ const validEnvEntries = Object.entries(env).filter(([k]) => {
234
+ if (!this.isValidEnvKey(k)) {
235
+ // 静默忽略非法环境变量名
236
+ return false;
237
+ }
238
+ return true;
239
+ });
240
+ if (validEnvEntries.length > 0) {
241
+ const envStr = validEnvEntries
242
+ .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
243
+ .join('; ');
244
+ fullCommand = `${envStr}; ${command}`;
245
+ }
246
+ }
247
+ if (options.cwd) {
248
+ fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`;
249
+ }
250
+ return new Promise((resolve, reject) => {
251
+ const timeout = options.timeout || this.defaultTimeout;
252
+ let timeoutId = null;
253
+ let stdout = '';
254
+ let stderr = '';
255
+ let stdoutTruncated = false;
256
+ let stderrTruncated = false;
257
+ const execOptions = {};
258
+ // PTY 模式
259
+ if (options.pty) {
260
+ execOptions.pty = {
261
+ rows: options.rows || 24,
262
+ cols: options.cols || 80,
263
+ term: options.term || 'xterm-256color',
264
+ };
265
+ }
266
+ session.client.exec(fullCommand, execOptions, (err, stream) => {
267
+ if (err) {
268
+ reject(new Error(`Exec failed: ${err.message}`));
269
+ return;
270
+ }
271
+ // 设置超时
272
+ timeoutId = setTimeout(() => {
273
+ stream.close();
274
+ reject(new Error(`Command timed out after ${timeout}ms`));
275
+ }, timeout);
276
+ stream.on('close', (code) => {
277
+ if (timeoutId)
278
+ clearTimeout(timeoutId);
279
+ resolve({
280
+ success: code === 0,
281
+ stdout: stdoutTruncated ? stdout + '\n... [truncated]' : stdout,
282
+ stderr: stderrTruncated ? stderr + '\n... [truncated]' : stderr,
283
+ exitCode: code,
284
+ duration: Date.now() - startTime,
285
+ });
286
+ });
287
+ stream.on('data', (data) => {
288
+ if (!stdoutTruncated) {
289
+ const chunk = data.toString('utf-8');
290
+ if (stdout.length + chunk.length > maxOutputSize) {
291
+ stdout += chunk.slice(0, maxOutputSize - stdout.length);
292
+ stdoutTruncated = true;
293
+ }
294
+ else {
295
+ stdout += chunk;
296
+ }
297
+ }
298
+ });
299
+ stream.stderr.on('data', (data) => {
300
+ if (!stderrTruncated) {
301
+ const chunk = data.toString('utf-8');
302
+ if (stderr.length + chunk.length > maxOutputSize) {
303
+ stderr += chunk.slice(0, maxOutputSize - stderr.length);
304
+ stderrTruncated = true;
305
+ }
306
+ else {
307
+ stderr += chunk;
308
+ }
309
+ }
310
+ });
311
+ });
312
+ });
313
+ }
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
+ /**
327
+ * 以其他用户身份执行命令
328
+ */
329
+ async execAsUser(alias, command, targetUser, options = {}) {
330
+ // 校验用户名防止注入
331
+ if (!this.isValidUsername(targetUser)) {
332
+ throw new Error(`Invalid username: ${targetUser}`);
333
+ }
334
+ const suCommand = `su - ${targetUser} -c ${this.escapeShellArg(command)}`;
335
+ return this.exec(alias, suCommand, options);
336
+ }
337
+ /**
338
+ * 使用 sudo 执行命令
339
+ */
340
+ async execSudo(alias, command, sudoPassword, options = {}) {
341
+ let sudoCommand;
342
+ if (sudoPassword) {
343
+ // 通过 stdin 传递密码(使用 escapeShellArg 转义)
344
+ sudoCommand = `echo ${this.escapeShellArg(sudoPassword)} | sudo -S ${command}`;
345
+ }
346
+ else {
347
+ sudoCommand = `sudo ${command}`;
348
+ }
349
+ return this.exec(alias, sudoCommand, options);
350
+ }
351
+ /**
352
+ * 获取 SFTP 客户端
353
+ */
354
+ getSftp(alias) {
355
+ const session = this.getSession(alias);
356
+ return new Promise((resolve, reject) => {
357
+ session.client.sftp((err, sftp) => {
358
+ if (err)
359
+ reject(err);
360
+ else
361
+ resolve(sftp);
362
+ });
363
+ });
364
+ }
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
+ /**
389
+ * 加载持久化的会话信息(仅用于显示,不自动重连)
390
+ */
391
+ loadPersistedSessions() {
392
+ try {
393
+ if (fs.existsSync(this.persistPath)) {
394
+ return JSON.parse(fs.readFileSync(this.persistPath, 'utf-8'));
395
+ }
396
+ }
397
+ catch { }
398
+ return [];
399
+ }
400
+ // ========== PTY 会话管理 ==========
401
+ /**
402
+ * 生成 PTY 会话 ID
403
+ */
404
+ generatePtyId() {
405
+ return `pty_${++this.ptyIdCounter}_${Date.now()}`;
406
+ }
407
+ /**
408
+ * 启动持久化 PTY 会话
409
+ */
410
+ async ptyStart(alias, command, options = {}) {
411
+ const session = this.getSession(alias);
412
+ const ptyId = this.generatePtyId();
413
+ const rows = options.rows || 24;
414
+ const cols = options.cols || 80;
415
+ const term = options.term || 'xterm-256color';
416
+ const maxBufferSize = options.bufferSize || this.defaultPtyBufferSize;
417
+ // 构建完整命令
418
+ let fullCommand = command;
419
+ const env = { ...session.config.env, ...options.env };
420
+ if (Object.keys(env).length > 0) {
421
+ const envStr = Object.entries(env)
422
+ .map(([k, v]) => `export ${k}=${this.escapeShellArg(v)}`)
423
+ .join('; ');
424
+ fullCommand = `${envStr}; ${command}`;
425
+ }
426
+ if (options.cwd) {
427
+ fullCommand = `cd ${this.escapeShellArg(options.cwd)} && ${fullCommand}`;
428
+ }
429
+ return new Promise((resolve, reject) => {
430
+ session.client.exec(fullCommand, {
431
+ pty: { rows, cols, term },
432
+ }, (err, stream) => {
433
+ if (err) {
434
+ reject(new Error(`PTY start failed: ${err.message}`));
435
+ return;
436
+ }
437
+ // 创建 xterm headless 终端仿真器
438
+ const terminal = new Terminal({
439
+ rows,
440
+ cols,
441
+ allowProposedApi: true,
442
+ });
443
+ const ptySession = {
444
+ id: ptyId,
445
+ alias,
446
+ command,
447
+ stream,
448
+ terminal,
449
+ rows,
450
+ cols,
451
+ createdAt: Date.now(),
452
+ lastReadAt: Date.now(),
453
+ rawBuffer: '',
454
+ maxBufferSize,
455
+ active: true,
456
+ };
457
+ // 监听输出数据
458
+ stream.on('data', (data) => {
459
+ if (!ptySession.active)
460
+ return;
461
+ const chunk = data.toString('utf-8');
462
+ // 写入终端仿真器(解析 ANSI 序列)
463
+ terminal.write(chunk);
464
+ // 同时保留原始流(用于 raw 模式)
465
+ ptySession.rawBuffer += chunk;
466
+ if (ptySession.rawBuffer.length > maxBufferSize) {
467
+ ptySession.rawBuffer = ptySession.rawBuffer.slice(-maxBufferSize);
468
+ }
469
+ });
470
+ stream.on('close', () => {
471
+ ptySession.active = false;
472
+ });
473
+ stream.on('error', (err) => {
474
+ ptySession.active = false;
475
+ terminal.write(`\n[PTY Error: ${err.message}]`);
476
+ });
477
+ this.ptySessions.set(ptyId, ptySession);
478
+ resolve(ptyId);
479
+ });
480
+ });
481
+ }
482
+ /**
483
+ * 向 PTY 写入数据
484
+ */
485
+ ptyWrite(ptyId, data) {
486
+ const ptySession = this.ptySessions.get(ptyId);
487
+ if (!ptySession) {
488
+ throw new Error(`PTY session '${ptyId}' not found`);
489
+ }
490
+ if (!ptySession.active) {
491
+ throw new Error(`PTY session '${ptyId}' is closed`);
492
+ }
493
+ return ptySession.stream.write(data);
494
+ }
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
+ /**
514
+ * 读取 PTY 输出
515
+ * @param mode 'screen' 返回当前屏幕内容,'raw' 返回原始 ANSI 流
516
+ */
517
+ ptyRead(ptyId, options = {}) {
518
+ const ptySession = this.ptySessions.get(ptyId);
519
+ if (!ptySession) {
520
+ throw new Error(`PTY session '${ptyId}' not found`);
521
+ }
522
+ const mode = options.mode || 'screen';
523
+ const clear = options.clear !== false;
524
+ let data;
525
+ if (mode === 'screen') {
526
+ // 返回当前屏幕内容(解析后的纯文本)
527
+ data = this.getScreenContent(ptySession.terminal);
528
+ }
529
+ else {
530
+ // 返回原始 ANSI 流
531
+ data = ptySession.rawBuffer;
532
+ if (clear) {
533
+ ptySession.rawBuffer = '';
534
+ }
535
+ }
536
+ ptySession.lastReadAt = Date.now();
537
+ return {
538
+ data,
539
+ active: ptySession.active,
540
+ rows: ptySession.rows,
541
+ cols: ptySession.cols,
542
+ };
543
+ }
544
+ /**
545
+ * 调整 PTY 窗口大小
546
+ */
547
+ ptyResize(ptyId, rows, cols) {
548
+ const ptySession = this.ptySessions.get(ptyId);
549
+ if (!ptySession) {
550
+ throw new Error(`PTY session '${ptyId}' not found`);
551
+ }
552
+ if (!ptySession.active) {
553
+ throw new Error(`PTY session '${ptyId}' is closed`);
554
+ }
555
+ // 调整远程 PTY 窗口
556
+ ptySession.stream.setWindow(rows, cols, 0, 0);
557
+ // 调整本地终端仿真器
558
+ ptySession.terminal.resize(cols, rows);
559
+ ptySession.rows = rows;
560
+ ptySession.cols = cols;
561
+ return true;
562
+ }
563
+ /**
564
+ * 关闭 PTY 会话
565
+ */
566
+ ptyClose(ptyId) {
567
+ const ptySession = this.ptySessions.get(ptyId);
568
+ if (!ptySession) {
569
+ return false;
570
+ }
571
+ try {
572
+ ptySession.stream.close();
573
+ }
574
+ catch { }
575
+ try {
576
+ ptySession.terminal.dispose();
577
+ }
578
+ catch { }
579
+ ptySession.active = false;
580
+ this.ptySessions.delete(ptyId);
581
+ return true;
582
+ }
583
+ /**
584
+ * 列出所有 PTY 会话
585
+ */
586
+ ptyList() {
587
+ const result = [];
588
+ for (const [id, pty] of this.ptySessions) {
589
+ result.push({
590
+ id,
591
+ alias: pty.alias,
592
+ command: pty.command,
593
+ rows: pty.rows,
594
+ cols: pty.cols,
595
+ createdAt: pty.createdAt,
596
+ lastReadAt: pty.lastReadAt,
597
+ bufferSize: pty.rawBuffer.length,
598
+ active: pty.active,
599
+ });
600
+ }
601
+ return result;
602
+ }
603
+ /**
604
+ * 关闭所有 PTY 会话
605
+ */
606
+ ptyCloseAll() {
607
+ let count = 0;
608
+ for (const ptyId of this.ptySessions.keys()) {
609
+ if (this.ptyClose(ptyId)) {
610
+ count++;
611
+ }
612
+ }
613
+ return count;
614
+ }
615
+ // ========== 端口转发 ==========
616
+ /**
617
+ * 生成端口转发 ID
618
+ */
619
+ generateForwardId() {
620
+ return `fwd_${++this.forwardIdCounter}_${Date.now()}`;
621
+ }
622
+ /**
623
+ * 创建本地端口转发
624
+ * 本地监听 localHost:localPort,转发到远程 remoteHost:remotePort
625
+ */
626
+ async forwardLocal(alias, localPort, remoteHost, remotePort, localHost = '127.0.0.1') {
627
+ const session = this.getSession(alias);
628
+ const forwardId = this.generateForwardId();
629
+ return new Promise((resolve, reject) => {
630
+ const server = net.createServer((socket) => {
631
+ session.client.forwardOut(socket.remoteAddress || '127.0.0.1', socket.remotePort || 0, remoteHost, remotePort, (err, stream) => {
632
+ if (err) {
633
+ socket.end();
634
+ return;
635
+ }
636
+ socket.pipe(stream).pipe(socket);
637
+ });
638
+ });
639
+ server.on('error', (err) => {
640
+ reject(new Error(`Local forward failed: ${err.message}`));
641
+ });
642
+ server.listen(localPort, localHost, () => {
643
+ const fwdSession = {
644
+ id: forwardId,
645
+ alias,
646
+ type: 'local',
647
+ localHost,
648
+ localPort,
649
+ remoteHost,
650
+ remotePort,
651
+ server,
652
+ createdAt: Date.now(),
653
+ active: true,
654
+ };
655
+ this.forwardSessions.set(forwardId, fwdSession);
656
+ resolve(forwardId);
657
+ });
658
+ });
659
+ }
660
+ /**
661
+ * 创建远程端口转发
662
+ * 远程监听 remoteHost:remotePort,转发到本地 localHost:localPort
663
+ */
664
+ async forwardRemote(alias, remotePort, localHost, localPort, remoteHost = '127.0.0.1') {
665
+ const session = this.getSession(alias);
666
+ const forwardId = this.generateForwardId();
667
+ return new Promise((resolve, reject) => {
668
+ session.client.forwardIn(remoteHost, remotePort, (err) => {
669
+ if (err) {
670
+ reject(new Error(`Remote forward failed: ${err.message}`));
671
+ return;
672
+ }
673
+ const fwdSession = {
674
+ id: forwardId,
675
+ alias,
676
+ type: 'remote',
677
+ localHost,
678
+ localPort,
679
+ remoteHost,
680
+ remotePort,
681
+ createdAt: Date.now(),
682
+ active: true,
683
+ };
684
+ this.forwardSessions.set(forwardId, fwdSession);
685
+ // 确保该 session 有共享的 tcp dispatcher
686
+ this.ensureTcpDispatcher(session, alias);
687
+ resolve(forwardId);
688
+ });
689
+ });
690
+ }
691
+ /**
692
+ * 确保 SSH session 有共享的 tcp connection dispatcher
693
+ * 所有 remote forward 共用一个 dispatcher,根据 destIP/destPort 路由
694
+ * @param alias - session 的 map key
695
+ */
696
+ ensureTcpDispatcher(session, alias) {
697
+ if (session.tcpDispatcher) {
698
+ return; // 已存在
699
+ }
700
+ const dispatcher = (info, accept, rejectConn) => {
701
+ // 查找匹配的 remote forward
702
+ for (const fwd of this.forwardSessions.values()) {
703
+ if (fwd.type === 'remote' &&
704
+ fwd.active &&
705
+ fwd.alias === alias &&
706
+ fwd.remoteHost === info.destIP &&
707
+ fwd.remotePort === info.destPort) {
708
+ // 找到匹配的 forward,建立连接
709
+ const stream = accept();
710
+ const socket = net.createConnection(fwd.localPort, fwd.localHost);
711
+ socket.pipe(stream).pipe(socket);
712
+ socket.on('error', () => stream.close());
713
+ stream.on('error', () => socket.destroy());
714
+ return;
715
+ }
716
+ }
717
+ // 没有匹配的 forward,拒绝连接
718
+ rejectConn();
719
+ };
720
+ session.tcpDispatcher = dispatcher;
721
+ session.client.on('tcp connection', dispatcher);
722
+ }
723
+ /**
724
+ * 移除 SSH session 的 tcp dispatcher(当没有 remote forward 时)
725
+ * @param alias - session 的 map key
726
+ */
727
+ removeTcpDispatcherIfEmpty(session, alias) {
728
+ if (!session.tcpDispatcher) {
729
+ return;
730
+ }
731
+ // 检查是否还有该 session 的 remote forward
732
+ for (const fwd of this.forwardSessions.values()) {
733
+ if (fwd.type === 'remote' && fwd.alias === alias && fwd.active) {
734
+ return; // 还有活跃的 remote forward
735
+ }
736
+ }
737
+ // 没有了,移除 dispatcher
738
+ session.client.removeListener('tcp connection', session.tcpDispatcher);
739
+ session.tcpDispatcher = undefined;
740
+ }
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
+ }
791
+ // 全局单例
792
+ export const sessionManager = new SessionManager();