@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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README_zh.md +353 -0
- package/dist/file-ops.d.ts +78 -0
- package/dist/file-ops.js +572 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +804 -0
- package/dist/session-manager.d.ts +193 -0
- package/dist/session-manager.js +792 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.js +4 -0
- package/package.json +43 -0
- package/src/file-ops.ts +730 -0
- package/src/index.ts +938 -0
- package/src/session-manager.ts +982 -0
- package/src/types.ts +133 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Session Manager - 连接池管理
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 连接池复用
|
|
6
|
+
* - 心跳保持
|
|
7
|
+
* - 自动重连
|
|
8
|
+
* - 会话持久化
|
|
9
|
+
*/
|
|
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';
|
|
18
|
+
import {
|
|
19
|
+
SSHConnectionConfig,
|
|
20
|
+
SSHSessionInfo,
|
|
21
|
+
ExecOptions,
|
|
22
|
+
ExecResult,
|
|
23
|
+
PersistedSession,
|
|
24
|
+
PtyOptions,
|
|
25
|
+
PtySessionInfo,
|
|
26
|
+
PortForwardConfig,
|
|
27
|
+
PortForwardInfo
|
|
28
|
+
} from './types.js';
|
|
29
|
+
|
|
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
|
|
38
|
+
}
|
|
39
|
+
|
|
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;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// tcp connection 事件处理函数类型
|
|
56
|
+
type TcpConnectionHandler = (
|
|
57
|
+
info: { destIP: string; destPort: number; srcIP: string; srcPort: number },
|
|
58
|
+
accept: () => ClientChannel,
|
|
59
|
+
reject: () => void
|
|
60
|
+
) => void;
|
|
61
|
+
|
|
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;
|
|
73
|
+
}
|
|
74
|
+
|
|
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;
|
|
184
|
+
}
|
|
185
|
+
reject(new Error(`SSH connection failed: ${err.message}`));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
client.on('close', () => {
|
|
189
|
+
const session = this.sessions.get(alias);
|
|
190
|
+
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;
|
|
405
|
+
}
|
|
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;
|
|
417
|
+
}
|
|
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);
|
|
606
|
+
}
|
|
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;
|
|
802
|
+
}
|
|
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) {
|
|
947
|
+
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
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 全局单例
|
|
982
|
+
export const sessionManager = new SessionManager();
|