mcp-ssh-server-tool 2.0.1 → 2.1.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 +97 -4
- package/index.js +9 -4
- package/package.json +8 -2
- package/readonly_policy.js +297 -0
- package/ssh_manager.js +167 -40
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- 🔑 **公钥认证** - 使用私钥文件连接到 SSH 服务器
|
|
9
9
|
- 💻 **远程命令执行** - 在连接的服务器上执行命令并获取输出
|
|
10
10
|
- 📊 **会话管理** - 管理多个 SSH 连接会话
|
|
11
|
+
- 🛡️ **按需留痕** - 只读命令走轻量通道,变更命令走可审计通道(见下)
|
|
11
12
|
|
|
12
13
|
## 安装
|
|
13
14
|
|
|
@@ -34,13 +35,18 @@ npx -y mcp-ssh-server-tool
|
|
|
34
35
|
在 SSH 会话中执行命令
|
|
35
36
|
|
|
36
37
|
**参数:**
|
|
37
|
-
- `
|
|
38
|
-
- `
|
|
38
|
+
- `host` / `username` / `password` 等:单次执行时传入,自动连接→执行→断开
|
|
39
|
+
- `session_id`: 会话 ID(连续执行多命令时使用,先调 `ssh_connect` 获取)
|
|
40
|
+
- `command`: 要执行的命令(单条)
|
|
41
|
+
- `commands`: 命令数组(按顺序执行)
|
|
42
|
+
- `audit`: 留痕控制,取值 `"force"`(可选,见下节)
|
|
39
43
|
|
|
40
44
|
**返回:**
|
|
41
45
|
- `stdout`: 标准输出
|
|
42
46
|
- `stderr`: 标准错误
|
|
43
47
|
- `exitCode`: 退出码
|
|
48
|
+
- `channel`: 实际使用的通道(`exec` | `shell`)
|
|
49
|
+
- `channelReason`: 选择该通道的原因
|
|
44
50
|
|
|
45
51
|
### ssh_disconnect
|
|
46
52
|
断开 SSH 连接
|
|
@@ -51,6 +57,77 @@ npx -y mcp-ssh-server-tool
|
|
|
51
57
|
### ssh_list_sessions
|
|
52
58
|
列出所有活动的 SSH 会话
|
|
53
59
|
|
|
60
|
+
## 按需留痕(audit)
|
|
61
|
+
|
|
62
|
+
### 背景
|
|
63
|
+
|
|
64
|
+
在堡垒机(JumpServer / KoKo 等)场景下,SSH 通道类型决定了会话是否被录像:
|
|
65
|
+
|
|
66
|
+
| SSH 通道 | 堡垒机归类 | 是否录像 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `exec`(`client.exec()`) | 命令执行 | ❌ 不支持录像 |
|
|
69
|
+
| `shell`(`client.shell()`) | 正常会话 | ✅ 完整录像 |
|
|
70
|
+
|
|
71
|
+
> 注意:是否分配 PTY **不影响**这个判定。`exec` 即使带 `pty: true`,仍归为「命令执行」、不录像。
|
|
72
|
+
|
|
73
|
+
这意味着:如果所有命令都走 `exec`,变更操作在堡垒机侧**没有操作级审计**;如果都走 `shell`,日常只读巡检又会灌满审计记录。
|
|
74
|
+
|
|
75
|
+
### 行为
|
|
76
|
+
|
|
77
|
+
`ssh_exec` 会按命令性质自动选择通道:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
ssh_exec(command)
|
|
81
|
+
├─ audit === 'force' → shell 通道(强制留痕)
|
|
82
|
+
├─ 命中只读白名单 → exec 通道(不留痕,快且输出干净)
|
|
83
|
+
└─ 其余一律 → shell 通道(留痕)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**默认留痕,且不提供「关闭留痕」的选项。**
|
|
87
|
+
|
|
88
|
+
这样判错的失败方向是单向的:
|
|
89
|
+
|
|
90
|
+
- 只读命令被误判为需留痕 → 多一条审计记录(无害)
|
|
91
|
+
- 变更命令被误判为只读 → 静默执行且不留痕(**最坏情况,已在设计上避免**)
|
|
92
|
+
|
|
93
|
+
### 只读判定
|
|
94
|
+
|
|
95
|
+
判定逻辑在 `readonly_policy.js`,采用「默认拒绝 + 显式约束」:
|
|
96
|
+
|
|
97
|
+
- 不在白名单里的命令 → 一律留痕
|
|
98
|
+
- 能读也能写的命令(`ip` / `docker` / `kubectl` / `find` / `sed` …)必须显式约束写形态
|
|
99
|
+
- 无法静态分析的形态(`>` 重定向、`$(...)` 命令替换、管道进 shell)直接否决
|
|
100
|
+
- 语义级写操作由 `denyPattern` 拦截(如 `awk` 的 `system()`、`sysctl` 的 `key=value`、`mysql` 的 `INTO OUTFILE`)
|
|
101
|
+
|
|
102
|
+
可通过 `loadPolicy(jsonPath)` 传入 JSON 覆盖内置白名单:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"ls": {},
|
|
107
|
+
"tail": { "denyArgs": ["-f", "--follow"] },
|
|
108
|
+
"sed": { "denyArgs": ["-i", "--in-place"] },
|
|
109
|
+
"systemctl": { "sub": ["status", "is-active", "list-units"] },
|
|
110
|
+
"mysql": { "requirePattern": "\\b(SELECT|SHOW)\\b" }
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
字段说明:
|
|
115
|
+
|
|
116
|
+
| 字段 | 含义 |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `sub` | 允许出现的子命令/位置参数,命中任一即通过;省略 = 不限制 |
|
|
119
|
+
| `denyArgs` | 禁止出现的参数,命中任一即否决 |
|
|
120
|
+
| `denyPattern` | 正则,匹配到即否决(语义级写操作) |
|
|
121
|
+
| `requirePattern` | 正则,必须匹配才通过(如 SQL 必须是查询语句) |
|
|
122
|
+
|
|
123
|
+
键名规则:命令名中的 `-` 换成 `_`(如 `redis-cli` → `redis_cli`)。
|
|
124
|
+
|
|
125
|
+
### 环境变量
|
|
126
|
+
|
|
127
|
+
| 变量 | 默认 | 说明 |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `SSH_SHELL_TIMEOUT_MS` | `30000` | shell 通道单条命令的超时上限(毫秒) |
|
|
130
|
+
|
|
54
131
|
## 使用示例
|
|
55
132
|
|
|
56
133
|
### 1. 密码认证连接
|
|
@@ -86,7 +163,17 @@ npx -y mcp-ssh-server-tool
|
|
|
86
163
|
}
|
|
87
164
|
```
|
|
88
165
|
|
|
89
|
-
### 4.
|
|
166
|
+
### 4. 强制留痕执行(变更操作)
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"session_id": "<session_id>",
|
|
171
|
+
"command": "systemctl restart nginx",
|
|
172
|
+
"audit": "force"
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 5. 断开连接
|
|
90
177
|
|
|
91
178
|
```json
|
|
92
179
|
{
|
|
@@ -94,11 +181,17 @@ npx -y mcp-ssh-server-tool
|
|
|
94
181
|
}
|
|
95
182
|
```
|
|
96
183
|
|
|
184
|
+
## 已知限制
|
|
185
|
+
|
|
186
|
+
- **命令以 `exit` / `exec` 结尾会终止 shell**:哨兵无法回显,命令会走到超时。此时仍会返回已捕获的输出,但 `exitCode` 为 `null`。
|
|
187
|
+
- **`exitCode` 取的是最后一条命令的退出码**:`a; b; c` 形式的复合命令,返回的是 `c` 的退出码。
|
|
188
|
+
- **shell 通道有固定开销**:每次都要走一次 shell 初始化(MOTD 等),比 `exec` 慢约 1~2 秒。
|
|
189
|
+
- **只读判定是安全网,不是证明**:它做静态字符串分析,绕过方式是存在的。真正的强约束应放在服务端(如堡垒机的命令过滤 ACL)。
|
|
190
|
+
|
|
97
191
|
## 依赖
|
|
98
192
|
|
|
99
193
|
- Node.js >= 18.0.0
|
|
100
194
|
- ssh2 >= 1.15.0
|
|
101
|
-
- uuid >= 9.0.0
|
|
102
195
|
|
|
103
196
|
## License
|
|
104
197
|
|
package/index.js
CHANGED
|
@@ -40,6 +40,11 @@ const tools = [
|
|
|
40
40
|
session_id: { type: 'string', description: '会话 ID(连续执行多命令时使用,先调用 ssh_connect 获取)' },
|
|
41
41
|
command: { type: 'string', description: '要执行的命令(单个命令)' },
|
|
42
42
|
commands: { type: 'array', items: { type: 'string' }, description: '要执行的命令列表(多个命令按顺序执行)' },
|
|
43
|
+
audit: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
enum: ['force'],
|
|
46
|
+
description: '留痕控制。不传时由只读白名单自动判定:只读命令走 exec 通道(快、不产生审计记录),其余一律走 shell 通道(产生 JumpServer 可回放会话)。传 force 则强制走 shell 通道留痕。没有「关闭留痕」选项,留痕保证不可被调用方绕过。',
|
|
47
|
+
},
|
|
43
48
|
},
|
|
44
49
|
},
|
|
45
50
|
},
|
|
@@ -166,9 +171,9 @@ async function handleRequest(request) {
|
|
|
166
171
|
|
|
167
172
|
let result;
|
|
168
173
|
if (toolArgs.commands) {
|
|
169
|
-
result = await sshManager.execCommands(conn.sessionId, toolArgs.commands);
|
|
174
|
+
result = await sshManager.execCommands(conn.sessionId, toolArgs.commands, { audit: toolArgs.audit });
|
|
170
175
|
} else if (toolArgs.command) {
|
|
171
|
-
result = await sshManager.execCommand(conn.sessionId, toolArgs.command);
|
|
176
|
+
result = await sshManager.execCommand(conn.sessionId, toolArgs.command, { audit: toolArgs.audit });
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
sshManager.disconnect(conn.sessionId);
|
|
@@ -179,9 +184,9 @@ async function handleRequest(request) {
|
|
|
179
184
|
if (hasSession) {
|
|
180
185
|
let result;
|
|
181
186
|
if (toolArgs.commands) {
|
|
182
|
-
result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands);
|
|
187
|
+
result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands, { audit: toolArgs.audit });
|
|
183
188
|
} else if (toolArgs.command) {
|
|
184
|
-
result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command);
|
|
189
|
+
result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command, { audit: toolArgs.audit });
|
|
185
190
|
}
|
|
186
191
|
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
187
192
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-ssh-server-tool",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "MCP Server for SSH connections and remote command execution",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node index.js"
|
|
12
12
|
},
|
|
13
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"ssh",
|
|
16
|
+
"server",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"remote-execution"
|
|
19
|
+
],
|
|
14
20
|
"author": "",
|
|
15
21
|
"license": "MIT",
|
|
16
22
|
"repository": {
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readonly_policy.js —— 只读命令判定(默认拒绝)
|
|
3
|
+
*
|
|
4
|
+
* 用途:决定一条命令走哪条 SSH 通道
|
|
5
|
+
* 命中只读白名单 → exec 通道(快、干净、不产生审计记录)
|
|
6
|
+
* 其余一律 → shell 通道(koko 归为 normal 会话,完整录像)
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 本模块是「安全网」,不是「证明」。
|
|
9
|
+
* 判错的代价是单向的:把写命令误判为只读 → 它会被自动执行且不留痕(最坏情况)。
|
|
10
|
+
* 因此设计上取保守策略:
|
|
11
|
+
* 1. 默认拒绝 —— 不在白名单里的一律留痕
|
|
12
|
+
* 2. 能读也能写的命令(ip / docker / kubectl / find / sed …)必须显式约束
|
|
13
|
+
* 3. 无法静态分析的形态(重定向、命令替换、管道进 shell)直接否决
|
|
14
|
+
* 4. 有 `denyPattern` 用于拦截语义级写操作(awk 的 system()、sysctl 的 key=value)
|
|
15
|
+
*
|
|
16
|
+
* 真正的强约束在服务端:JumpServer 的命令过滤 ACL(acls_command_filter_acls)。
|
|
17
|
+
* 本模块只负责「该不该留痕」,不负责「能不能执行」。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
|
|
22
|
+
// 无参数约束的纯查看命令(不改变任何系统状态)
|
|
23
|
+
const PURE_READ = {
|
|
24
|
+
ls: 1, ll: 1, dir: 1, vdir: 1, pwd: 1, cat: 1, head: 1, tac: 1, nl: 1, wc: 1,
|
|
25
|
+
stat: 1, file: 1, du: 1, df: 1, tree: 1, which: 1, whereis: 1, type: 1,
|
|
26
|
+
readlink: 1, realpath: 1, basename: 1, dirname: 1, locate: 1,
|
|
27
|
+
md5sum: 1, sha1sum: 1, sha256sum: 1, cksum: 1, cmp: 1, diff: 1, comm: 1,
|
|
28
|
+
strings: 1, xxd: 1, od: 1, hexdump: 1,
|
|
29
|
+
grep: 1, egrep: 1, fgrep: 1, rg: 1, zgrep: 1, zcat: 1, zless: 1,
|
|
30
|
+
cut: 1, sort: 1, uniq: 1, tr: 1, column: 1, paste: 1, join: 1, fold: 1,
|
|
31
|
+
expand: 1, rev: 1, jq: 1, yq: 1,
|
|
32
|
+
uname: 1, hostname: 1, arch: 1, nproc: 1, getconf: 1, uptime: 1, date: 1, cal: 1,
|
|
33
|
+
lscpu: 1, lsblk: 1, lspci: 1, lsusb: 1, lsmod: 1, modinfo: 1,
|
|
34
|
+
free: 1, vmstat: 1, mpstat: 1, iostat: 1, sar: 1, pidstat: 1,
|
|
35
|
+
ps: 1, pstree: 1, pidof: 1, pgrep: 1, lsof: 1,
|
|
36
|
+
w: 1, who: 1, whoami: 1, id: 1, groups: 1, users: 1, last: 1, lastlog: 1, lastb: 1,
|
|
37
|
+
runlevel: 1, ausearch: 1, aureport: 1, findmnt: 1,
|
|
38
|
+
lvs: 1, vgs: 1, pvs: 1, lvdisplay: 1, vgdisplay: 1, pvdisplay: 1,
|
|
39
|
+
xfs_info: 1, dumpe2fs: 1, quota: 1,
|
|
40
|
+
netstat: 1, ss: 1, dig: 1, nslookup: 1, host: 1, getent: 1,
|
|
41
|
+
getfacl: 1, lsattr: 1, systemd_analyze: 1, atq: 1,
|
|
42
|
+
env: 1, printenv: 1, echo: 1, printf: 1, history: 1,
|
|
43
|
+
ping: 1, ping6: 1, traceroute: 1, tracepath: 1, mtr: 1,
|
|
44
|
+
iptables_save: 1, ip6tables_save: 1,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// 需要约束的命令
|
|
48
|
+
const CONSTRAINED = {
|
|
49
|
+
// --- 可读可写,必须挡住写形态 ---
|
|
50
|
+
tail: { denyArgs: ['-f', '--follow', '-F', '--follow=name'] },
|
|
51
|
+
find: { denyArgs: ['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'] },
|
|
52
|
+
sed: { denyArgs: ['-i', '--in-place'], denyPattern: /\bw\s+\S/ },
|
|
53
|
+
awk: { denyPattern: /system\s*\(|\|\s*["']?\s*(sh|bash)\b|print(f)?\s*>>?\s*["']?\// },
|
|
54
|
+
sort: { denyArgs: ['-o', '--output'] },
|
|
55
|
+
|
|
56
|
+
// --- 磁盘 / 文件系统:只放行明确的只读形态 ---
|
|
57
|
+
fdisk: { sub: ['-l', '--list'] },
|
|
58
|
+
sfdisk: { sub: ['-l', '--list', '-d', '--dump'] },
|
|
59
|
+
gdisk: { sub: ['-l', '--list'] },
|
|
60
|
+
parted: { sub: ['print', '-l', '--list'] },
|
|
61
|
+
blkid: { denyArgs: ['-g', '--garbage-collect'] },
|
|
62
|
+
smartctl: { denyArgs: ['-t', '--test', '-s', '--set', '-o', '--offlineauto'] },
|
|
63
|
+
tune2fs: { sub: ['-l'] },
|
|
64
|
+
e2fsck: { sub: ['-n'] },
|
|
65
|
+
// mount 已移除:无参数时是查看,带参数就是挂载,无法静态区分。
|
|
66
|
+
// 查看挂载请用 findmnt 或 cat /proc/mounts。
|
|
67
|
+
|
|
68
|
+
// --- 内核参数:sysctl -w 与 key=value 两种写法都要挡 ---
|
|
69
|
+
sysctl: { denyArgs: ['-w', '--write', '-p', '--load'], denyPattern: /[A-Za-z0-9_.]+\s*=/ },
|
|
70
|
+
|
|
71
|
+
// --- dmesg:-c/-C 会清空环形缓冲区 ---
|
|
72
|
+
dmesg: { denyArgs: ['-c', '-C', '--read-clear', '--clear', '-D', '--delete'] },
|
|
73
|
+
|
|
74
|
+
// --- 网络:这些命令读和写的子命令长得很像 ---
|
|
75
|
+
ip: {
|
|
76
|
+
sub: ['addr', 'a', 'link', 'route', 'r', 'neigh', 'n', '-s', '-d', 'maddr', 'rule'],
|
|
77
|
+
denyArgs: ['set', 'add', 'del', 'delete', 'change', 'replace', 'append', 'flush', 'up', 'down'],
|
|
78
|
+
},
|
|
79
|
+
ifconfig: {
|
|
80
|
+
denyArgs: ['up', 'down', 'mtu', 'promisc', '-promisc', 'arp', '-arp', 'add', 'del', 'netmask', 'broadcast', 'txqueuelen'],
|
|
81
|
+
denyPattern: /\b\d{1,3}(\.\d{1,3}){3}\b/,
|
|
82
|
+
},
|
|
83
|
+
route: { denyArgs: ['add', 'del', 'delete', 'change', 'replace', 'flush'] },
|
|
84
|
+
arp: { denyArgs: ['-d', '-s', '--delete', '--set', '-f', '--file'] },
|
|
85
|
+
ethtool: {
|
|
86
|
+
denyArgs: ['-s', '--change', '-A', '--pause', '-G', '--set-ring', '-K', '--features',
|
|
87
|
+
'-L', '--set-channels', '-N', '--set-ntuple', '-W', '--set-dump', '-E', '--eeprom', '-p', '--blink'],
|
|
88
|
+
},
|
|
89
|
+
conntrack: { denyArgs: ['-F', '-D', '-I', '-U', '--flush', '--delete', '--create', '--update'] },
|
|
90
|
+
nc: { denyArgs: ['-l', '-e', '-c', '--listen', '--exec'] },
|
|
91
|
+
nmcli: {
|
|
92
|
+
sub: ['device', 'connection', 'general', 'networking', 'radio', 'monitor', 'status', 'show'],
|
|
93
|
+
denyArgs: ['up', 'down', 'modify', 'add', 'delete', 'connect', 'disconnect', 'reload',
|
|
94
|
+
'on', 'off', 'create', 'remove', 'set', 'import'],
|
|
95
|
+
},
|
|
96
|
+
firewall_cmd: {
|
|
97
|
+
sub: ['--list-all', '--list-all-zones', '--state', '--list-ports', '--list-services',
|
|
98
|
+
'--list-zones', '--get-zones', '--get-default-zone', '--list-rich-rules',
|
|
99
|
+
'--list-icmp-blocks', '--list-interfaces', '--query-panic'],
|
|
100
|
+
},
|
|
101
|
+
iptables: {
|
|
102
|
+
sub: ['-L', '--list', '-S', '--list-rules'],
|
|
103
|
+
denyArgs: ['-A', '-I', '-D', '-F', '-N', '-X', '-P', '-Z', '-E', '-R', '-C'],
|
|
104
|
+
},
|
|
105
|
+
nft: { sub: ['list'] },
|
|
106
|
+
|
|
107
|
+
// --- 服务与日志 ---
|
|
108
|
+
systemctl: {
|
|
109
|
+
sub: ['status', 'is-active', 'is-enabled', 'is-failed', 'list-units', 'list-unit-files',
|
|
110
|
+
'show', 'cat', 'list-dependencies', 'list-timers', 'list-jobs', 'list-sockets',
|
|
111
|
+
'--version', '--failed', 'get-default'],
|
|
112
|
+
},
|
|
113
|
+
service: { sub: ['status'] },
|
|
114
|
+
chkconfig: { sub: ['--list'] },
|
|
115
|
+
journalctl: { denyArgs: ['-f', '--follow', '--vacuum-time', '--vacuum-size', '--vacuum-files', '--rotate', '--flush'] },
|
|
116
|
+
|
|
117
|
+
// --- 进程 ---
|
|
118
|
+
fuser: { denyArgs: ['-k', '--kill'] },
|
|
119
|
+
|
|
120
|
+
// --- 包管理:只放行清单/查询类子命令 ---
|
|
121
|
+
rpm: { sub: ['-qa', '-q', '-qi', '-ql', '-qf', '-qc', '-qd', '-qR', '-V', '--verify'] },
|
|
122
|
+
yum: { sub: ['list', 'info', 'repolist', 'check-update'], denyArgs: ['undo', 'rollback', 'redo', 'install', 'remove', 'erase', 'update', 'upgrade', 'autoremove', 'clean', 'mark', 'group'] },
|
|
123
|
+
dnf: { sub: ['list', 'info', 'repolist', 'check-update', 'history'], denyArgs: ['undo', 'rollback', 'redo', 'install', 'remove', 'erase', 'update', 'upgrade', 'autoremove', 'clean', 'mark', 'group'] },
|
|
124
|
+
apt: { sub: ['list', 'show', 'search'] },
|
|
125
|
+
apt_cache: { sub: ['policy', 'show', 'search'] },
|
|
126
|
+
dpkg: { sub: ['-l', '-s', '-L', '--get-selections', '-l'] },
|
|
127
|
+
zypper: { sub: ['search', 'info', 'list-updates', 'packages'] },
|
|
128
|
+
pacman: { sub: ['-Q', '-Qi', '-Ql', '-Qs', '-Qe'] },
|
|
129
|
+
|
|
130
|
+
// --- 容器 / K8s ---
|
|
131
|
+
docker: {
|
|
132
|
+
sub: ['ps', 'images', 'logs', 'inspect', 'stats', 'version', 'info', 'top', 'port',
|
|
133
|
+
'diff', 'network', 'volume', 'system', 'image', 'container', 'events', 'history'],
|
|
134
|
+
denyArgs: ['run', 'create', 'rm', 'rmi', 'start', 'stop', 'restart', 'kill', 'exec',
|
|
135
|
+
'build', 'push', 'pull', 'prune', 'pause', 'unpause', 'update', 'commit',
|
|
136
|
+
'tag', 'login', 'logout', 'import', 'load', 'save', 'swarm', 'stack',
|
|
137
|
+
'plugin', 'attach', 'cp', 'rename', 'wait'],
|
|
138
|
+
},
|
|
139
|
+
podman: {
|
|
140
|
+
sub: ['ps', 'images', 'logs', 'inspect', 'version', 'info', 'top', 'stats'],
|
|
141
|
+
denyArgs: ['run', 'create', 'rm', 'rmi', 'start', 'stop', 'restart', 'kill', 'exec',
|
|
142
|
+
'build', 'push', 'pull', 'prune', 'pause', 'unpause', 'commit', 'tag', 'cp'],
|
|
143
|
+
},
|
|
144
|
+
crictl: {
|
|
145
|
+
sub: ['ps', 'pods', 'images', 'inspect', 'logs', 'stats', 'version', 'info'],
|
|
146
|
+
denyArgs: ['rm', 'rmi', 'stop', 'start', 'run', 'create', 'pull', 'update', 'exec'],
|
|
147
|
+
},
|
|
148
|
+
ctr: {
|
|
149
|
+
sub: ['c', 'n', 's', 'i'],
|
|
150
|
+
denyArgs: ['rm', 'remove', 'pull', 'push', 'create', 'run', 'start', 'stop', 'kill', 'delete'],
|
|
151
|
+
},
|
|
152
|
+
kubectl: {
|
|
153
|
+
sub: ['get', 'describe', 'logs', 'top', 'version', 'config', 'explain',
|
|
154
|
+
'cluster-info', 'api-resources', 'auth', 'events'],
|
|
155
|
+
denyArgs: ['set', 'unset', 'delete', 'apply', 'create', 'edit', 'patch', 'replace',
|
|
156
|
+
'scale', 'rollout', 'exec', 'port-forward', 'drain', 'cordon', 'uncordon',
|
|
157
|
+
'label', 'annotate', 'cp', 'taint', 'run', 'expose', 'attach', 'debug',
|
|
158
|
+
'set-context', 'set-cluster', 'set-credentials', 'use-context',
|
|
159
|
+
'rename-context', 'delete-context', 'delete-cluster', 'delete-user'],
|
|
160
|
+
},
|
|
161
|
+
helm: {
|
|
162
|
+
sub: ['list', 'status', 'get', 'history', 'template', 'show', 'search', 'version', 'env'],
|
|
163
|
+
denyArgs: ['install', 'upgrade', 'uninstall', 'rollback', 'repo', 'plugin', 'pull', 'push', 'package', 'dependency'],
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
// --- 中间件:靠 requirePattern 限定为查询语句 ---
|
|
167
|
+
mysql: {
|
|
168
|
+
requirePattern: /\b(SELECT|SHOW|DESC|DESCRIBE|EXPLAIN)\b/i,
|
|
169
|
+
denyPattern: /INTO\s+(OUTFILE|DUMPFILE)/i,
|
|
170
|
+
},
|
|
171
|
+
mysqladmin: { sub: ['status', 'processlist', 'version', 'variables', 'ping'] },
|
|
172
|
+
psql: {
|
|
173
|
+
requirePattern: /\b(SELECT|SHOW|TABLE|EXPLAIN)\b/i,
|
|
174
|
+
denyPattern: /INTO\s+(OUTFILE|DUMPFILE)|\bCOPY\b[\s\S]*\bTO\b/i,
|
|
175
|
+
},
|
|
176
|
+
mongosh: {
|
|
177
|
+
requirePattern: /\b(db\.stats|db\.\S+\.(find|findOne|count|aggregate)|show\s+(dbs|collections|tables))\b/i,
|
|
178
|
+
},
|
|
179
|
+
redis_cli: {
|
|
180
|
+
requirePattern: /\b(GET|MGET|INFO|TTL|PTTL|SCAN|KEYS|DBSIZE|LLEN|LRANGE|HGET|HGETALL|SMEMBERS|SISMEMBER|ZRANGE|ZCARD|TYPE|EXISTS|STRLEN|MEMORY|CLIENT\s+LIST|SLOWLOG\s+GET|--scan)\b/i,
|
|
181
|
+
},
|
|
182
|
+
rabbitmqctl: { sub: ['list_queues', 'status', 'list_connections', 'list_exchanges', 'list_channels', 'list_nodes', 'list_vhosts', 'environment'] },
|
|
183
|
+
kafka_topics: { sub: ['--list', '--describe'] },
|
|
184
|
+
kafka_consumer_groups: { sub: ['--list', '--describe'] },
|
|
185
|
+
nginx: { sub: ['-t', '-T', '-v', '-V'] },
|
|
186
|
+
apachectl: { sub: ['-t', '-S', '-M', '-v'] },
|
|
187
|
+
httpd: { sub: ['-t', '-S', '-M', '-v'] },
|
|
188
|
+
haproxy: { sub: ['-c', '-v', '-vv'] },
|
|
189
|
+
|
|
190
|
+
// --- 计划任务 / 权限 / 变量 ---
|
|
191
|
+
crontab: { sub: ['-l'] },
|
|
192
|
+
at: { sub: ['-l'] },
|
|
193
|
+
passwd: { sub: ['-S'] },
|
|
194
|
+
chage: { sub: ['-l'] },
|
|
195
|
+
visudo: { sub: ['-c'] },
|
|
196
|
+
export: { sub: ['-p'] },
|
|
197
|
+
declare: { sub: ['-p'] },
|
|
198
|
+
ulimit: { denyArgs: ['-n', '-u', '-c', '-f', '-s', '-t', '-v', '-m', '-l', '-p', '-q', '-r', '-x', '-i', '-e', '-d'] },
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const RULES = { ...PURE_READ, ...CONSTRAINED };
|
|
202
|
+
|
|
203
|
+
// 出现即整体判为「需留痕」的元字符(无法静态分析)
|
|
204
|
+
const FORBID_SUBSTRINGS = ['>', '>>', 'tee', '$(', '`', '<(', '<<<', '< '];
|
|
205
|
+
|
|
206
|
+
// 复合命令分隔符:拆开逐段判定,全段只读才放行
|
|
207
|
+
const COMPOUND_SPLIT = /(;|&&|\|\||\|)/;
|
|
208
|
+
|
|
209
|
+
let activeRules = RULES;
|
|
210
|
+
|
|
211
|
+
/** 可选:从 JSON 覆盖内置白名单 */
|
|
212
|
+
export function loadPolicy(policyPath) {
|
|
213
|
+
if (!policyPath) return;
|
|
214
|
+
try {
|
|
215
|
+
activeRules = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`[readonly_policy] 白名单加载失败,回退内置规则: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 单段命令(无复合分隔符)的判定 */
|
|
222
|
+
function isSingleReadonly(segment) {
|
|
223
|
+
const trimmed = segment.trim();
|
|
224
|
+
if (!trimmed) return true;
|
|
225
|
+
|
|
226
|
+
for (const bad of FORBID_SUBSTRINGS) {
|
|
227
|
+
if (trimmed.includes(bad)) return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
231
|
+
if (tokens.length === 0) return true;
|
|
232
|
+
|
|
233
|
+
// 命令名:去掉路径、把 - 归一为 _
|
|
234
|
+
const bin = tokens[0].split('/').pop().replace(/-/g, '_');
|
|
235
|
+
|
|
236
|
+
// 查合并后的规则表(不要直接查 PURE_READ —— 否则 CONSTRAINED 里的同名约束会被绕过)
|
|
237
|
+
const rule = RULES[bin];
|
|
238
|
+
if (!rule) return false; // 不在白名单 → 默认拒绝
|
|
239
|
+
if (rule === 1) return true; // 纯查看命令,无参数约束
|
|
240
|
+
|
|
241
|
+
// 子命令约束:允许出现的子命令/位置参数,命中任一即通过
|
|
242
|
+
if (Array.isArray(rule.sub) && rule.sub.length > 0) {
|
|
243
|
+
if (!tokens.slice(1).some((t) => rule.sub.includes(t))) return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 危险参数约束
|
|
247
|
+
if (Array.isArray(rule.denyArgs)) {
|
|
248
|
+
for (const bad of rule.denyArgs) {
|
|
249
|
+
if (tokens.includes(bad)) return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 语义级写操作拦截
|
|
254
|
+
if (rule.denyPattern && rule.denyPattern.test(trimmed)) return false;
|
|
255
|
+
|
|
256
|
+
// 内容形态约束(如 SQL 必须是 SELECT/SHOW)
|
|
257
|
+
if (rule.requirePattern && !rule.requirePattern.test(trimmed)) return false;
|
|
258
|
+
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 判定整条命令是否只读。
|
|
264
|
+
* 复合命令(; && || |)拆开后逐段判定,全部只读才返回 true。
|
|
265
|
+
*/
|
|
266
|
+
export function isReadonly(command) {
|
|
267
|
+
if (!command || !command.trim()) return false;
|
|
268
|
+
|
|
269
|
+
// 权限提升一律不放行
|
|
270
|
+
if (/(^|\s)(sudo|su|doas|pkexec)(\s|$)/.test(command)) return false;
|
|
271
|
+
|
|
272
|
+
const segments = command.split(COMPOUND_SPLIT).filter(
|
|
273
|
+
(s) => s && ![';', '&&', '||', '|'].includes(s.trim())
|
|
274
|
+
);
|
|
275
|
+
if (segments.length === 0) return false;
|
|
276
|
+
|
|
277
|
+
return segments.every(isSingleReadonly);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 决定命令走哪条通道。
|
|
282
|
+
*
|
|
283
|
+
* 优先级:
|
|
284
|
+
* 1. audit === 'force' → 'shell'(强制留痕,即使命中白名单)
|
|
285
|
+
* 2. 命中只读白名单 → 'exec' (不留痕)
|
|
286
|
+
* 3. 其余 → 'shell'(留痕)
|
|
287
|
+
*
|
|
288
|
+
* 注意:不提供 audit === 'off'。留痕保证不可被调用方关闭 ——
|
|
289
|
+
* 否则「变更操作走审计」就只是一句口号,而不是机制。
|
|
290
|
+
*/
|
|
291
|
+
export function resolveChannel(command, audit) {
|
|
292
|
+
if (audit === 'force') return { channel: 'shell', reason: 'audit=force 强制留痕' };
|
|
293
|
+
if (isReadonly(command)) return { channel: 'exec', reason: '命中只读白名单' };
|
|
294
|
+
return { channel: 'shell', reason: '非只读命令,默认留痕' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const _rules = { PURE_READ, CONSTRAINED };
|
package/ssh_manager.js
CHANGED
|
@@ -1,7 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_manager.js —— 改造版
|
|
3
|
+
*
|
|
4
|
+
* 相对原版的唯一结构性改动:execCommand 会按命令性质选择 SSH 通道。
|
|
5
|
+
*
|
|
6
|
+
* 只读命令 → exec 通道 → koko 归为「命令执行」→ 不产生录像(快、干净)
|
|
7
|
+
* 非只读命令 → shell 通道 → koko 归为「正常会话」→ 完整录像(审计留痕)
|
|
8
|
+
*
|
|
9
|
+
* 依据:实测证明 koko 按 SSH 通道类型决定是否录像,与是否分配 PTY 无关。
|
|
10
|
+
* exec() → type: command → error_reason: replay_unsupported
|
|
11
|
+
* exec({pty:true}) → type: command → error_reason: replay_unsupported
|
|
12
|
+
* shell({...pty}) → type: normal → has_replay: true
|
|
13
|
+
*/
|
|
14
|
+
|
|
1
15
|
import { Client } from 'ssh2';
|
|
2
16
|
import { randomUUID } from 'crypto';
|
|
3
17
|
import * as fs from 'fs';
|
|
4
18
|
import * as path from 'path';
|
|
19
|
+
import { resolveChannel } from './readonly_policy.js';
|
|
20
|
+
|
|
21
|
+
// shell 通道的超时兜底(命令卡住时哨兵不出现,必须强退,否则 MCP 调用悬挂)
|
|
22
|
+
// 可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖
|
|
23
|
+
const SHELL_TIMEOUT_MS = Number(process.env.SSH_SHELL_TIMEOUT_MS || 30000);
|
|
24
|
+
|
|
25
|
+
// 哨兵用的随机串。故意让「发送的命令行」里只出现 ${N} 而非展开值,
|
|
26
|
+
// 这样 shell 回显的输入行不会与真实标记串冲突。
|
|
27
|
+
const NONCE = '7f3a2c';
|
|
28
|
+
const START_MARK = `__CMD_BEGIN_${NONCE}__`;
|
|
29
|
+
const END_MARK = `__CMD_END_${NONCE}__`;
|
|
30
|
+
|
|
31
|
+
// ANSI / OSC / 控制字符清洗
|
|
32
|
+
const OSC_RE = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
|
|
33
|
+
const CSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-PRZcf-nqry=><])/g;
|
|
34
|
+
const CTRL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
35
|
+
|
|
36
|
+
export function stripAnsi(s) {
|
|
37
|
+
return String(s)
|
|
38
|
+
.replace(OSC_RE, '')
|
|
39
|
+
.replace(CSI_RE, '')
|
|
40
|
+
.replace(CTRL_RE, '')
|
|
41
|
+
.replace(/\r/g, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 去掉首尾空行
|
|
45
|
+
function trimBlank(s) {
|
|
46
|
+
return s.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
47
|
+
}
|
|
5
48
|
|
|
6
49
|
class SSHSession {
|
|
7
50
|
constructor(sessionId, client, config) {
|
|
@@ -34,16 +77,14 @@ class SSHConnectionManager {
|
|
|
34
77
|
|
|
35
78
|
const timeout = setTimeout(() => {
|
|
36
79
|
client.end();
|
|
37
|
-
resolve({
|
|
38
|
-
success: false,
|
|
39
|
-
error: 'Connection timeout',
|
|
40
|
-
errorType: 'Timeout',
|
|
41
|
-
});
|
|
80
|
+
resolve({ success: false, error: 'Connection timeout', errorType: 'Timeout' });
|
|
42
81
|
}, (options.timeout || 10) * 1000);
|
|
43
82
|
|
|
44
83
|
client.on('ready', () => {
|
|
45
84
|
clearTimeout(timeout);
|
|
46
|
-
const session = new SSHSession(sessionId, client, {
|
|
85
|
+
const session = new SSHSession(sessionId, client, {
|
|
86
|
+
host: config.host, port: config.port, username: config.username,
|
|
87
|
+
});
|
|
47
88
|
this.sessions.set(sessionId, session);
|
|
48
89
|
resolve({
|
|
49
90
|
success: true,
|
|
@@ -57,11 +98,7 @@ class SSHConnectionManager {
|
|
|
57
98
|
|
|
58
99
|
client.on('error', (err) => {
|
|
59
100
|
clearTimeout(timeout);
|
|
60
|
-
resolve({
|
|
61
|
-
success: false,
|
|
62
|
-
error: err.message,
|
|
63
|
-
errorType: 'ConnectionError',
|
|
64
|
-
});
|
|
101
|
+
resolve({ success: false, error: err.message, errorType: 'ConnectionError' });
|
|
65
102
|
});
|
|
66
103
|
|
|
67
104
|
client.connect(config);
|
|
@@ -77,7 +114,18 @@ class SSHConnectionManager {
|
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
|
|
80
|
-
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 通道选择入口
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} sessionId
|
|
123
|
+
* @param {string} command
|
|
124
|
+
* @param {{audit?: 'force'|undefined}} [options]
|
|
125
|
+
* audit === 'force' → 强制走 shell 通道留痕
|
|
126
|
+
* 其余情况由 readonly_policy 判定
|
|
127
|
+
*/
|
|
128
|
+
async execCommand(sessionId, command, options = {}) {
|
|
81
129
|
const session = this.sessions.get(sessionId);
|
|
82
130
|
if (!session) {
|
|
83
131
|
return {
|
|
@@ -87,15 +135,34 @@ class SSHConnectionManager {
|
|
|
87
135
|
};
|
|
88
136
|
}
|
|
89
137
|
|
|
138
|
+
const { channel, reason } = resolveChannel(command, options.audit);
|
|
139
|
+
|
|
140
|
+
const result = channel === 'shell'
|
|
141
|
+
? await this._execViaShell(session, command)
|
|
142
|
+
: await this._execViaExec(session, command);
|
|
143
|
+
|
|
144
|
+
return { ...result, channel, channelReason: reason };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async execCommands(sessionId, commands, options = {}) {
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const cmd of commands) {
|
|
150
|
+
const result = await this.execCommand(sessionId, cmd, options);
|
|
151
|
+
results.push(result);
|
|
152
|
+
if (!result.success) break;
|
|
153
|
+
}
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// exec 通道:只读命令,快、干净、不产生审计记录
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
_execViaExec(session, command) {
|
|
90
162
|
return new Promise((resolve) => {
|
|
91
163
|
session.client.exec(command, (err, stream) => {
|
|
92
164
|
if (err) {
|
|
93
|
-
resolve({
|
|
94
|
-
success: false,
|
|
95
|
-
error: err.message,
|
|
96
|
-
errorType: 'ExecError',
|
|
97
|
-
sessionId,
|
|
98
|
-
});
|
|
165
|
+
resolve({ success: false, error: err.message, errorType: 'ExecError', sessionId: session.sessionId });
|
|
99
166
|
return;
|
|
100
167
|
}
|
|
101
168
|
|
|
@@ -103,14 +170,7 @@ class SSHConnectionManager {
|
|
|
103
170
|
let stderr = '';
|
|
104
171
|
|
|
105
172
|
stream.on('close', (code) => {
|
|
106
|
-
resolve({
|
|
107
|
-
success: true,
|
|
108
|
-
sessionId,
|
|
109
|
-
command,
|
|
110
|
-
stdout,
|
|
111
|
-
stderr,
|
|
112
|
-
exitCode: code,
|
|
113
|
-
});
|
|
173
|
+
resolve({ success: true, sessionId: session.sessionId, command, stdout, stderr, exitCode: code });
|
|
114
174
|
});
|
|
115
175
|
|
|
116
176
|
stream.on('data', (data) => { stdout += data.toString(); });
|
|
@@ -119,24 +179,91 @@ class SSHConnectionManager {
|
|
|
119
179
|
});
|
|
120
180
|
}
|
|
121
181
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// shell 通道:非只读命令,产生可回放的 JumpServer 会话
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
_execViaShell(session, command) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
session.client.shell(
|
|
189
|
+
{ term: 'xterm-256color', cols: 200, rows: 50 },
|
|
190
|
+
(err, stream) => {
|
|
191
|
+
if (err) {
|
|
192
|
+
resolve({ success: false, error: err.message, errorType: 'ShellError', sessionId: session.sessionId });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let buf = '';
|
|
197
|
+
let settled = false;
|
|
198
|
+
|
|
199
|
+
const finish = (payload) => {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
try { stream.end(); } catch { /* ignore */ }
|
|
204
|
+
resolve({ ...payload, sessionId: session.sessionId, command });
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
// 超时也要交出已捕获的输出:命令可能是 exit / exec 这类
|
|
209
|
+
// 会直接终止 shell 的,哨兵永远不会出现
|
|
210
|
+
const k = buf.indexOf(START_MARK);
|
|
211
|
+
const partial = k === -1
|
|
212
|
+
? ''
|
|
213
|
+
: trimBlank(stripAnsi(buf.slice(k + START_MARK.length)));
|
|
214
|
+
finish({
|
|
215
|
+
success: false,
|
|
216
|
+
error: `命令超时(${SHELL_TIMEOUT_MS}ms)。会话已在 JumpServer 留痕`,
|
|
217
|
+
errorType: 'Timeout',
|
|
218
|
+
stdout: partial,
|
|
219
|
+
exitCode: null,
|
|
220
|
+
});
|
|
221
|
+
}, SHELL_TIMEOUT_MS);
|
|
222
|
+
|
|
223
|
+
stream.on('data', (d) => {
|
|
224
|
+
buf += d.toString();
|
|
225
|
+
|
|
226
|
+
const i = buf.indexOf(START_MARK);
|
|
227
|
+
if (i === -1) return;
|
|
228
|
+
const j = buf.indexOf(END_MARK, i + START_MARK.length);
|
|
229
|
+
if (j === -1) return;
|
|
230
|
+
|
|
231
|
+
// 退出码:END_MARK 之后紧跟的数字
|
|
232
|
+
const tail = buf.slice(j + END_MARK.length);
|
|
233
|
+
const m = tail.match(/^(\d+)/);
|
|
234
|
+
if (!m) return; // 退出码还没回显完,继续等
|
|
235
|
+
|
|
236
|
+
const body = buf.slice(i + START_MARK.length, j);
|
|
237
|
+
|
|
238
|
+
finish({
|
|
239
|
+
success: true,
|
|
240
|
+
stdout: trimBlank(stripAnsi(body)),
|
|
241
|
+
// pty 下 stderr 与 stdout 合并,内容都在 stdout 里
|
|
242
|
+
stderr: '',
|
|
243
|
+
exitCode: parseInt(m[1], 10),
|
|
244
|
+
note: '该会话已由 JumpServer 录像,可在控制台回放',
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
stream.stderr.on('data', (d) => { buf += d.toString(); });
|
|
249
|
+
|
|
250
|
+
// 用 shell 变量包住标记,避免回显行与真实标记串冲突;
|
|
251
|
+
// 注意必须写 ${N} 而非 $N —— 下划线是合法变量名字符,
|
|
252
|
+
// $N__ 会被 bash 解析成变量 N__(未定义),标记串就生成不出来。
|
|
253
|
+
stream.write(
|
|
254
|
+
`N=${NONCE}; echo "__CMD_BEGIN_\${N}__"; ${command}; echo "__CMD_END_\${N}__$?"\n`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
});
|
|
130
259
|
}
|
|
131
260
|
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
132
263
|
disconnect(sessionId) {
|
|
133
264
|
const session = this.sessions.get(sessionId);
|
|
134
265
|
if (!session) {
|
|
135
|
-
return {
|
|
136
|
-
success: false,
|
|
137
|
-
error: `Session ${sessionId} not found`,
|
|
138
|
-
errorType: 'SessionNotFound',
|
|
139
|
-
};
|
|
266
|
+
return { success: false, error: `Session ${sessionId} not found`, errorType: 'SessionNotFound' };
|
|
140
267
|
}
|
|
141
268
|
|
|
142
269
|
try {
|
|
@@ -149,7 +276,7 @@ class SSHConnectionManager {
|
|
|
149
276
|
}
|
|
150
277
|
|
|
151
278
|
listSessions() {
|
|
152
|
-
const sessions = Array.from(this.sessions.values()).map(s => ({
|
|
279
|
+
const sessions = Array.from(this.sessions.values()).map((s) => ({
|
|
153
280
|
sessionId: s.sessionId,
|
|
154
281
|
host: s.config.host,
|
|
155
282
|
username: s.config.username,
|