mcp-ssh-server-tool 2.1.0 → 2.2.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/README.md +48 -0
- package/index.js +41 -5
- package/package.json +33 -33
- package/ssh_manager.js +93 -14
package/README.md
CHANGED
|
@@ -181,12 +181,60 @@ ssh_exec(command)
|
|
|
181
181
|
}
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
+
## 命令复核自动应答(auto_confirm_review)
|
|
185
|
+
|
|
186
|
+
### 背景
|
|
187
|
+
|
|
188
|
+
堡垒机(JumpServer)若开了**命令过滤 ACL** 且动作是「审批」,命中其命令组正则的命令会被 koko 拦下,
|
|
189
|
+
在终端弹出交互提示:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
需要复核,是否继续?[Y/N]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`exec` 通道没有 stdin,应答不了这个提示 —— 命令会一直卡住直到超时。
|
|
196
|
+
本工具的非只读命令走的是 **PTY shell 通道**,所以**有能力把 `y` 写回去**。
|
|
197
|
+
|
|
198
|
+
### 语义澄清(重要)
|
|
199
|
+
|
|
200
|
+
**写 `y` 只是「提交审批申请」,命令不会立刻执行** —— 仍需审批人在 JumpServer 侧批准。
|
|
201
|
+
所以本功能**不绕过任何管控**,只是把「人工在终端按 y」这一步自动化了。
|
|
202
|
+
|
|
203
|
+
> ⚠️ 代价:工单的「申请人」会显示为当前堡垒机用户,但实际是工具自动发起的。
|
|
204
|
+
> 审批人无法从工单上区分「本人按的」与「工具自动按的」。启用前请确认这符合你们的管控要求。
|
|
205
|
+
|
|
206
|
+
### 行为
|
|
207
|
+
|
|
208
|
+
| `auto_confirm_review` | 检测到复核提示时 |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `false`(默认) | **立刻返回** `errorType: "ReviewRequired"`,不再白等到超时;`stdout` 里带上提示原文 |
|
|
211
|
+
| `true` | 自动写 `y` 提交申请,然后把看门狗放宽到 `review_wait`(默认 10 分钟)等审批 |
|
|
212
|
+
|
|
213
|
+
返回值里新增两个字段,便于调用方判断:
|
|
214
|
+
|
|
215
|
+
- `reviewPrompted` —— 是否检测到复核提示
|
|
216
|
+
- `autoConfirmed` —— 是否已自动应答
|
|
217
|
+
|
|
218
|
+
**审批通过后**,shell 会继续执行原命令并回显结束哨兵 —— 也就是说
|
|
219
|
+
**现有解析逻辑无需改动**,命令正常执行、`exitCode` 正常返回。
|
|
220
|
+
超时未获批则返回 `errorType: "ReviewPending"`。
|
|
221
|
+
|
|
222
|
+
### 开关
|
|
223
|
+
|
|
224
|
+
- 每次调用传参:`auto_confirm_review: true`
|
|
225
|
+
- 全局默认开启:环境变量 `SSH_AUTO_CONFIRM_REVIEW=1`
|
|
226
|
+
- 等待时长:`review_wait`(秒)或环境变量 `SSH_REVIEW_WAIT_MS`(毫秒)
|
|
227
|
+
|
|
184
228
|
## 已知限制
|
|
185
229
|
|
|
186
230
|
- **命令以 `exit` / `exec` 结尾会终止 shell**:哨兵无法回显,命令会走到超时。此时仍会返回已捕获的输出,但 `exitCode` 为 `null`。
|
|
187
231
|
- **`exitCode` 取的是最后一条命令的退出码**:`a; b; c` 形式的复合命令,返回的是 `c` 的退出码。
|
|
188
232
|
- **shell 通道有固定开销**:每次都要走一次 shell 初始化(MOTD 等),比 `exec` 慢约 1~2 秒。
|
|
189
233
|
- **只读判定是安全网,不是证明**:它做静态字符串分析,绕过方式是存在的。真正的强约束应放在服务端(如堡垒机的命令过滤 ACL)。
|
|
234
|
+
- **复核提示的识别靠关键词匹配**(`需要复核` / `是否继续` / `是否提交申请` / `命令需要审批`)。
|
|
235
|
+
若目标环境的提示文案不同,需同步调整 `ssh_manager.js` 里的 `REVIEW_PROMPT_RE`;
|
|
236
|
+
文案不匹配时表现为「没识别到提示,走普通超时」。
|
|
237
|
+
- **自动应答只在 shell 通道生效**:只读命令走 `exec` 通道,本来也不会被拦(除非 ACL 覆盖了只读命令名)。
|
|
190
238
|
|
|
191
239
|
## 依赖
|
|
192
240
|
|
package/index.js
CHANGED
|
@@ -45,6 +45,18 @@ const tools = [
|
|
|
45
45
|
enum: ['force'],
|
|
46
46
|
description: '留痕控制。不传时由只读白名单自动判定:只读命令走 exec 通道(快、不产生审计记录),其余一律走 shell 通道(产生 JumpServer 可回放会话)。传 force 则强制走 shell 通道留痕。没有「关闭留痕」选项,留痕保证不可被调用方绕过。',
|
|
47
47
|
},
|
|
48
|
+
auto_confirm_review: {
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
description: '遇到 JumpServer「命令复核」提示(如「需要复核,是否继续?[Y/N]」)时,是否自动应答 y 以提交审批申请。默认 false(此时会立刻返回 ReviewRequired 错误,不白等超时);也可用环境变量 SSH_AUTO_CONFIRM_REVIEW=1 设为全局默认。注意:写 y 只是提交申请,命令仍需审批人批准后才会执行 —— 不绕过任何管控。',
|
|
51
|
+
},
|
|
52
|
+
shell_timeout: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'shell 通道的命令超时(秒)。默认 30,可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖。',
|
|
55
|
+
},
|
|
56
|
+
review_wait: {
|
|
57
|
+
type: 'number',
|
|
58
|
+
description: 'auto_confirm_review 生效后,等待审批人处理的时长(秒)。默认 600(10 分钟),可用环境变量 SSH_REVIEW_WAIT_MS 覆盖。',
|
|
59
|
+
},
|
|
48
60
|
},
|
|
49
61
|
},
|
|
50
62
|
},
|
|
@@ -75,6 +87,18 @@ const tools = [
|
|
|
75
87
|
passphrase: { type: 'string', description: '私钥密码' },
|
|
76
88
|
timeout: { type: 'number', description: '超时时间(秒)', default: 10 },
|
|
77
89
|
test_command: { type: 'string', description: '测试命令(默认: echo test)', default: 'echo test' },
|
|
90
|
+
auto_confirm_review: {
|
|
91
|
+
type: 'boolean',
|
|
92
|
+
description: '遇到 JumpServer「命令复核」提示时是否自动应答 y 提交审批申请。默认 false;也可用环境变量 SSH_AUTO_CONFIRM_REVIEW=1 设为全局默认。写 y 只是提交申请,命令仍需审批人批准。',
|
|
93
|
+
},
|
|
94
|
+
shell_timeout: {
|
|
95
|
+
type: 'number',
|
|
96
|
+
description: 'shell 通道的命令超时(秒)。默认 30,可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖。',
|
|
97
|
+
},
|
|
98
|
+
review_wait: {
|
|
99
|
+
type: 'number',
|
|
100
|
+
description: 'auto_confirm_review 生效后,等待审批人处理的时长(秒)。默认 600,可用环境变量 SSH_REVIEW_WAIT_MS 覆盖。',
|
|
101
|
+
},
|
|
78
102
|
},
|
|
79
103
|
required: ['host', 'username'],
|
|
80
104
|
},
|
|
@@ -147,6 +171,14 @@ async function handleRequest(request) {
|
|
|
147
171
|
const hasSession = toolArgs.session_id;
|
|
148
172
|
const hasAuth = toolArgs.host && toolArgs.username;
|
|
149
173
|
|
|
174
|
+
// 统一整理执行选项(含「命令复核」自动应答与超时)
|
|
175
|
+
const execOpts = {
|
|
176
|
+
audit: toolArgs.audit,
|
|
177
|
+
autoConfirmReview: toolArgs.auto_confirm_review,
|
|
178
|
+
shellTimeoutMs: toolArgs.shell_timeout ? toolArgs.shell_timeout * 1000 : undefined,
|
|
179
|
+
reviewWaitMs: toolArgs.review_wait ? toolArgs.review_wait * 1000 : undefined,
|
|
180
|
+
};
|
|
181
|
+
|
|
150
182
|
if (!hasSession && !hasAuth) {
|
|
151
183
|
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Either session_id or host/username required' }) }] } });
|
|
152
184
|
return;
|
|
@@ -171,9 +203,9 @@ async function handleRequest(request) {
|
|
|
171
203
|
|
|
172
204
|
let result;
|
|
173
205
|
if (toolArgs.commands) {
|
|
174
|
-
result = await sshManager.execCommands(conn.sessionId, toolArgs.commands,
|
|
206
|
+
result = await sshManager.execCommands(conn.sessionId, toolArgs.commands, execOpts);
|
|
175
207
|
} else if (toolArgs.command) {
|
|
176
|
-
result = await sshManager.execCommand(conn.sessionId, toolArgs.command,
|
|
208
|
+
result = await sshManager.execCommand(conn.sessionId, toolArgs.command, execOpts);
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
sshManager.disconnect(conn.sessionId);
|
|
@@ -184,9 +216,9 @@ async function handleRequest(request) {
|
|
|
184
216
|
if (hasSession) {
|
|
185
217
|
let result;
|
|
186
218
|
if (toolArgs.commands) {
|
|
187
|
-
result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands,
|
|
219
|
+
result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands, execOpts);
|
|
188
220
|
} else if (toolArgs.command) {
|
|
189
|
-
result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command,
|
|
221
|
+
result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command, execOpts);
|
|
190
222
|
}
|
|
191
223
|
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
192
224
|
return;
|
|
@@ -216,7 +248,11 @@ async function handleRequest(request) {
|
|
|
216
248
|
return;
|
|
217
249
|
}
|
|
218
250
|
|
|
219
|
-
const execResult = await sshManager.execCommand(conn.sessionId, toolArgs.test_command || 'echo test'
|
|
251
|
+
const execResult = await sshManager.execCommand(conn.sessionId, toolArgs.test_command || 'echo test', {
|
|
252
|
+
autoConfirmReview: toolArgs.auto_confirm_review,
|
|
253
|
+
shellTimeoutMs: toolArgs.shell_timeout ? toolArgs.shell_timeout * 1000 : undefined,
|
|
254
|
+
reviewWaitMs: toolArgs.review_wait ? toolArgs.review_wait * 1000 : undefined,
|
|
255
|
+
});
|
|
220
256
|
sshManager.disconnect(conn.sessionId);
|
|
221
257
|
|
|
222
258
|
send({
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mcp-ssh-server-tool",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "MCP Server for SSH connections and remote command execution",
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"mcp-ssh-server-tool": "bin/cli.js"
|
|
9
|
-
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"start": "node index.js"
|
|
12
|
-
},
|
|
13
|
-
"keywords": [
|
|
14
|
-
"mcp",
|
|
15
|
-
"ssh",
|
|
16
|
-
"server",
|
|
17
|
-
"mcp-server",
|
|
18
|
-
"remote-execution"
|
|
19
|
-
],
|
|
20
|
-
"author": "",
|
|
21
|
-
"license": "MIT",
|
|
22
|
-
"repository": {
|
|
23
|
-
"type": "git",
|
|
24
|
-
"url": ""
|
|
25
|
-
},
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"ssh2": "^1.15.0"
|
|
28
|
-
},
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=18.0.0"
|
|
31
|
-
},
|
|
32
|
-
"preferGlobal": true
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-ssh-server-tool",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "MCP Server for SSH connections and remote command execution",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-ssh-server-tool": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node index.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"ssh",
|
|
16
|
+
"server",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"remote-execution"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": ""
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ssh2": "^1.15.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"preferGlobal": true
|
|
33
|
+
}
|
package/ssh_manager.js
CHANGED
|
@@ -22,6 +22,30 @@ import { resolveChannel } from './readonly_policy.js';
|
|
|
22
22
|
// 可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖
|
|
23
23
|
const SHELL_TIMEOUT_MS = Number(process.env.SSH_SHELL_TIMEOUT_MS || 30000);
|
|
24
24
|
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// JumpServer「命令复核」提示的识别与应答
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
//
|
|
29
|
+
// 堡垒机开启命令过滤(ACL action=review)后,命中其命令组正则的命令会被 koko 拦下,
|
|
30
|
+
// 在终端里弹出交互提示,形如:
|
|
31
|
+
//
|
|
32
|
+
// 需要复核,是否继续?[Y/N]
|
|
33
|
+
//
|
|
34
|
+
// 本工具走的是 **PTY shell 通道**,所以有能力把这个 y 写回去 —— 这就是「提交审批申请」。
|
|
35
|
+
//
|
|
36
|
+
// ⚠️ 语义澄清(很重要):
|
|
37
|
+
// 写 y 只是「提交申请」,命令**不会立刻执行** —— 仍需审批人在 JumpServer 侧批准。
|
|
38
|
+
// 所以本功能**不绕过任何管控**,只是把「人工在终端按 y」这一步自动化了。
|
|
39
|
+
// 代价:工单的「申请人」会显示为当前堡垒机用户,但实际是工具自动发起的,
|
|
40
|
+
// 审批人无法从工单上区分「本人按的」与「工具自动按的」。
|
|
41
|
+
//
|
|
42
|
+
// 开关与时长:
|
|
43
|
+
// SSH_AUTO_CONFIRM_REVIEW=1 把「自动应答」设为全局默认(每次调用也可用参数覆盖)
|
|
44
|
+
// SSH_REVIEW_WAIT_MS 自动应答后等待审批的时长,默认 10 分钟
|
|
45
|
+
const REVIEW_PROMPT_RE = /需要复核|是否继续|是否提交申请|命令需要审批/;
|
|
46
|
+
const REVIEW_WAIT_MS = Number(process.env.SSH_REVIEW_WAIT_MS || 10 * 60 * 1000);
|
|
47
|
+
const AUTO_CONFIRM_DEFAULT = process.env.SSH_AUTO_CONFIRM_REVIEW === '1';
|
|
48
|
+
|
|
25
49
|
// 哨兵用的随机串。故意让「发送的命令行」里只出现 ${N} 而非展开值,
|
|
26
50
|
// 这样 shell 回显的输入行不会与真实标记串冲突。
|
|
27
51
|
const NONCE = '7f3a2c';
|
|
@@ -137,8 +161,17 @@ class SSHConnectionManager {
|
|
|
137
161
|
|
|
138
162
|
const { channel, reason } = resolveChannel(command, options.audit);
|
|
139
163
|
|
|
164
|
+
// 未显式传参时,用环境变量决定的全局默认
|
|
165
|
+
const autoConfirmReview = options.autoConfirmReview === undefined
|
|
166
|
+
? AUTO_CONFIRM_DEFAULT
|
|
167
|
+
: options.autoConfirmReview === true;
|
|
168
|
+
|
|
140
169
|
const result = channel === 'shell'
|
|
141
|
-
? await this._execViaShell(session, command
|
|
170
|
+
? await this._execViaShell(session, command, {
|
|
171
|
+
autoConfirmReview,
|
|
172
|
+
shellTimeoutMs: options.shellTimeoutMs,
|
|
173
|
+
reviewWaitMs: options.reviewWaitMs,
|
|
174
|
+
})
|
|
142
175
|
: await this._execViaExec(session, command);
|
|
143
176
|
|
|
144
177
|
return { ...result, channel, channelReason: reason };
|
|
@@ -183,7 +216,11 @@ class SSHConnectionManager {
|
|
|
183
216
|
// shell 通道:非只读命令,产生可回放的 JumpServer 会话
|
|
184
217
|
// ---------------------------------------------------------------------------
|
|
185
218
|
|
|
186
|
-
_execViaShell(session, command) {
|
|
219
|
+
_execViaShell(session, command, opts = {}) {
|
|
220
|
+
const autoConfirm = opts.autoConfirmReview === true;
|
|
221
|
+
const baseTimeoutMs = opts.shellTimeoutMs || SHELL_TIMEOUT_MS;
|
|
222
|
+
const reviewWaitMs = opts.reviewWaitMs || REVIEW_WAIT_MS;
|
|
223
|
+
|
|
187
224
|
return new Promise((resolve) => {
|
|
188
225
|
session.client.shell(
|
|
189
226
|
{ term: 'xterm-256color', cols: 200, rows: 50 },
|
|
@@ -195,34 +232,74 @@ class SSHConnectionManager {
|
|
|
195
232
|
|
|
196
233
|
let buf = '';
|
|
197
234
|
let settled = false;
|
|
235
|
+
let reviewPrompted = false; // 是否检测到「命令复核」提示
|
|
236
|
+
let autoConfirmed = false; // 是否已自动应答 y
|
|
237
|
+
let timer = null;
|
|
238
|
+
|
|
239
|
+
// 取已捕获的输出(哨兵之后的部分)
|
|
240
|
+
const partialOut = () => {
|
|
241
|
+
const k = buf.indexOf(START_MARK);
|
|
242
|
+
return k === -1 ? '' : trimBlank(stripAnsi(buf.slice(k + START_MARK.length)));
|
|
243
|
+
};
|
|
198
244
|
|
|
199
245
|
const finish = (payload) => {
|
|
200
246
|
if (settled) return;
|
|
201
247
|
settled = true;
|
|
202
248
|
clearTimeout(timer);
|
|
203
249
|
try { stream.end(); } catch { /* ignore */ }
|
|
204
|
-
resolve({ ...payload, sessionId: session.sessionId, command });
|
|
250
|
+
resolve({ ...payload, sessionId: session.sessionId, command, reviewPrompted, autoConfirmed });
|
|
205
251
|
};
|
|
206
252
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
253
|
+
// 可重置的看门狗:超时也要交出已捕获的输出,
|
|
254
|
+
// 因为命令可能是 exit / exec 这类会直接终止 shell 的,哨兵永远不会出现
|
|
255
|
+
const armTimer = (ms, onTimeout) => {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
timer = setTimeout(onTimeout, ms);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
armTimer(baseTimeoutMs, () => {
|
|
214
261
|
finish({
|
|
215
262
|
success: false,
|
|
216
|
-
error: `命令超时(${
|
|
263
|
+
error: `命令超时(${baseTimeoutMs}ms)。会话已在 JumpServer 留痕`,
|
|
217
264
|
errorType: 'Timeout',
|
|
218
|
-
stdout:
|
|
265
|
+
stdout: partialOut(),
|
|
219
266
|
exitCode: null,
|
|
220
267
|
});
|
|
221
|
-
}
|
|
268
|
+
});
|
|
222
269
|
|
|
223
270
|
stream.on('data', (d) => {
|
|
224
271
|
buf += d.toString();
|
|
225
272
|
|
|
273
|
+
// ---- JumpServer「命令复核」拦截 ----
|
|
274
|
+
if (!reviewPrompted && REVIEW_PROMPT_RE.test(stripAnsi(buf))) {
|
|
275
|
+
reviewPrompted = true;
|
|
276
|
+
|
|
277
|
+
if (!autoConfirm) {
|
|
278
|
+
// 不自动应答:立刻给出明确错误,别让调用方白等到超时
|
|
279
|
+
finish({
|
|
280
|
+
success: false,
|
|
281
|
+
error: '命令被 JumpServer「命令复核」拦截:需在终端按 y 提交审批申请(本次未自动应答)',
|
|
282
|
+
errorType: 'ReviewRequired',
|
|
283
|
+
stdout: partialOut(),
|
|
284
|
+
exitCode: null,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 自动应答:写 y 提交审批申请,并把看门狗放宽到 reviewWaitMs
|
|
290
|
+
try { stream.write('y\n'); } catch { /* ignore */ }
|
|
291
|
+
autoConfirmed = true;
|
|
292
|
+
armTimer(reviewWaitMs, () => {
|
|
293
|
+
finish({
|
|
294
|
+
success: false,
|
|
295
|
+
error: `已提交审批申请,但等待 ${Math.round(reviewWaitMs / 1000)}s 仍未获批。会话已在 JumpServer 留痕`,
|
|
296
|
+
errorType: 'ReviewPending',
|
|
297
|
+
stdout: partialOut(),
|
|
298
|
+
exitCode: null,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
226
303
|
const i = buf.indexOf(START_MARK);
|
|
227
304
|
if (i === -1) return;
|
|
228
305
|
const j = buf.indexOf(END_MARK, i + START_MARK.length);
|
|
@@ -241,7 +318,9 @@ class SSHConnectionManager {
|
|
|
241
318
|
// pty 下 stderr 与 stdout 合并,内容都在 stdout 里
|
|
242
319
|
stderr: '',
|
|
243
320
|
exitCode: parseInt(m[1], 10),
|
|
244
|
-
note:
|
|
321
|
+
note: autoConfirmed
|
|
322
|
+
? '命令经审批后执行;该会话已由 JumpServer 录像,可在控制台回放'
|
|
323
|
+
: '该会话已由 JumpServer 录像,可在控制台回放',
|
|
245
324
|
});
|
|
246
325
|
});
|
|
247
326
|
|