@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.
- package/README.md +81 -72
- package/README_zh.md +81 -73
- package/dist/file-ops.js +36 -20
- package/dist/index.js +24 -9
- package/dist/session-manager.d.ts +62 -51
- package/dist/session-manager.js +201 -168
- package/dist/ssh-config.js +2 -2
- package/package.json +1 -1
- package/src/file-ops.ts +602 -577
- package/src/index.ts +971 -948
- package/src/session-manager.ts +986 -945
- package/src/ssh-config.ts +185 -185
- package/src/types.ts +89 -89
- package/tsconfig.json +7 -2
package/src/session-manager.ts
CHANGED
|
@@ -8,975 +8,1016 @@
|
|
|
8
8
|
* - 会话持久化
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
import * as fs from 'fs'
|
|
13
|
-
import * as
|
|
14
|
-
import
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
949
|
-
} catch {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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()
|