@zicolasjac-ai/remote-readonly-ssh 1.1.7 → 1.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 +8 -8
- package/bin/rrs.js +11 -2
- package/config.example.json +25 -15
- package/package.json +5 -3
- package/skills/remote-readonly-ssh/SKILL.md +1 -1
- package/src/config.js +68 -1
- package/src/init.js +93 -26
- package/src/jumpshell.js +103 -31
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
- **技术强制只读**:命令前缀白名单 + 禁止模式 + 参数防注入 + 目标主机白名单,多层校验。
|
|
10
10
|
- **审计留痕**:每次调用(含被拒绝的记录)写入本地审计日志,可随时审查。
|
|
11
|
-
-
|
|
11
|
+
- **极简依赖**:连接层用纯 JS 的 `ssh2`(无原生编译),认证组支持 **SSH 私钥 / 密码** 两种模式,可按 IP 分组配置(N 台主机共用一组凭据),跨平台行为一致。
|
|
12
12
|
- **AI Agent 友好**:附带 SKILL.md 可一键安装到 opencode / Claude / Cursor / Windsurf / Codex / OpenHands 等 AI 助手的 skills 目录。
|
|
13
13
|
|
|
14
14
|
## 开始使用
|
|
@@ -22,8 +22,8 @@ npx -y @zicolasjac-ai/remote-readonly-ssh@latest init
|
|
|
22
22
|
npx 会自动拉取最新版本、进入初始化向导,依次询问:
|
|
23
23
|
|
|
24
24
|
1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
|
|
25
|
-
2.
|
|
26
|
-
3. 目标主机白名单(每行 `ip
|
|
25
|
+
2. **认证组**:堡垒机主机 / 端口 / 用户名 / 认证模式(SSH 密钥或密码)/ 凭据;可配置多个认证组
|
|
26
|
+
3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`),并为每台主机指定归属的认证组(多台主机可共用同一组)
|
|
27
27
|
4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI 助手
|
|
28
28
|
|
|
29
29
|
向导结束后 `~/.rrs/config.json` 已就位、`~/.rrs/audit.log` 已建好、AI skill 已分发。直接试跑:
|
|
@@ -46,7 +46,7 @@ rrs sys-info --json # 直接用 rrs 前缀
|
|
|
46
46
|
>
|
|
47
47
|
> 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
|
|
48
48
|
|
|
49
|
-
要求 Node.js >= 18
|
|
49
|
+
要求 Node.js >= 18(连接层走内置 `ssh2`,无需系统 OpenSSH 客户端)。
|
|
50
50
|
|
|
51
51
|
## 用法
|
|
52
52
|
|
|
@@ -74,16 +74,16 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
74
74
|
|
|
75
75
|
| 键 | 说明 |
|
|
76
76
|
|-----|------|
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
77
|
+
| `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`list-tools` 只显示模式与主机,不显示任何凭据 |
|
|
78
|
+
| `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP)。**多台主机可共用同一认证组(N:1)** |
|
|
79
79
|
| `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
|
|
80
|
-
| `private_key_path` | SSH 私钥路径(Windows 建议放 `~/.ssh/locked/` 收紧 ACL;Linux/Mac 用 `chmod 600`) |
|
|
81
|
-
| `target_hosts` | 允许访问的目标主机白名单 `{ip: 堡垒机搜索关键字}` |
|
|
82
80
|
| `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
|
|
83
81
|
| `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
|
|
84
82
|
| `allowed_path_prefixes` | 允许读取的路径前缀 |
|
|
85
83
|
| `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
|
|
86
84
|
|
|
85
|
+
> **凭据安全提示**:密码为明文存放于本机 `config.json`(与私钥文件同级信任,仅本机可读)。Windows 建议收紧 ACL,Linux/macOS 建议 `chmod 600 ~/.rrs/config.json`。
|
|
86
|
+
|
|
87
87
|
审计日志:`~/.rrs/audit.log`
|
|
88
88
|
|
|
89
89
|
## Agent Skills
|
package/bin/rrs.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
|
-
const { loadConf, configDir, audit } = require("../src/config");
|
|
6
|
+
const { loadConf, configDir, audit, validateConfig } = require("../src/config");
|
|
7
7
|
const { JumpShell, cleanAnsi } = require("../src/jumpshell");
|
|
8
8
|
const {
|
|
9
9
|
validateCmd, validatePath, validateHost, validateArg, validateBin,
|
|
@@ -180,7 +180,9 @@ function listTools(conf) {
|
|
|
180
180
|
"exec-only <cmd>": "通用只读命令(白名单校验)",
|
|
181
181
|
};
|
|
182
182
|
for (const [k, v] of Object.entries(tools)) console.log(` ${k.padEnd(20)} ${v}`);
|
|
183
|
-
|
|
183
|
+
const profiles = conf.auth_profiles || {};
|
|
184
|
+
console.log(`\n认证组: ${Object.entries(profiles).map(([id, p]) => `${id}(${p.auth.mode}@${p.host})`).join(", ") || "(无)"}`);
|
|
185
|
+
console.log(`允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
|
|
184
186
|
console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
|
|
185
187
|
console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
|
|
186
188
|
console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
|
|
@@ -211,6 +213,13 @@ async function main() {
|
|
|
211
213
|
process.exit(e.exitCode || 1);
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
try {
|
|
217
|
+
validateConfig(conf);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
emitResult(opts, { ok: false, error: e.message, kind: "错误" });
|
|
220
|
+
process.exit(e.exitCode || 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
214
223
|
if (opts.tool === "list-tools") { listTools(conf); return; }
|
|
215
224
|
|
|
216
225
|
let host, cmds;
|
package/config.example.json
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
"auth_profiles": {
|
|
3
|
+
"profile_a": {
|
|
4
|
+
"host": "YOUR_BASTION_IP",
|
|
5
|
+
"port": 2222,
|
|
6
|
+
"username": "YOUR_USERNAME",
|
|
7
|
+
"auth": {
|
|
8
|
+
"mode": "key",
|
|
9
|
+
"private_key_path": "C:\\Users\\YOURNAME\\.ssh\\id_rsa",
|
|
10
|
+
"private_key_passphrase": null
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"profile_b": {
|
|
14
|
+
"host": "YOUR_BASTION_IP_2",
|
|
15
|
+
"port": 2222,
|
|
16
|
+
"username": "YOUR_USERNAME_2",
|
|
17
|
+
"auth": {
|
|
18
|
+
"mode": "password",
|
|
19
|
+
"password": "YOUR_PASSWORD"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"target_hosts": {
|
|
24
|
+
"10.0.0.1": { "profile": "profile_a", "keyword": "10.0.0" },
|
|
25
|
+
"10.0.0.2": { "profile": "profile_a", "keyword": "10.0.0" }
|
|
6
26
|
},
|
|
7
|
-
"ssh_options": [
|
|
8
|
-
"-o", "StrictHostKeyChecking=no",
|
|
9
|
-
"-o", "HostKeyAlgorithms=+ssh-rsa",
|
|
10
|
-
"-o", "PubkeyAcceptedAlgorithms=+ssh-rsa"
|
|
11
|
-
],
|
|
12
27
|
"audit": {
|
|
13
28
|
"enabled": true,
|
|
14
29
|
"include_output": false
|
|
15
30
|
},
|
|
16
|
-
"private_key_path": "C:\\Users\\YOURNAME\\.ssh\\id_rsa",
|
|
17
|
-
"target_hosts": {
|
|
18
|
-
"10.0.0.1": "10.0.0",
|
|
19
|
-
"10.0.0.2": "10.0.0"
|
|
20
|
-
},
|
|
21
31
|
"allowed_version_bins": [
|
|
22
32
|
"/usr/local/nginx/sbin/nginx",
|
|
23
33
|
"nginx",
|
|
@@ -71,4 +81,4 @@
|
|
|
71
81
|
"/home/",
|
|
72
82
|
"/etc/ssh/"
|
|
73
83
|
]
|
|
74
|
-
}
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;认证组支持 SSH 私钥/密码两种模式与按 IP 分组;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rrs": "bin/rrs.js"
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"claude",
|
|
34
34
|
"ai"
|
|
35
35
|
],
|
|
36
|
-
"dependencies": {
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"ssh2": "^1.17.0"
|
|
38
|
+
},
|
|
37
39
|
"scripts": {
|
|
38
40
|
"test": "node --test",
|
|
39
41
|
"postinstall": "node bin/postinstall.js",
|
|
@@ -32,7 +32,7 @@ metadata:
|
|
|
32
32
|
```bash
|
|
33
33
|
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
34
34
|
rrs init # 生成配置模板 ~/.rrs/config.json
|
|
35
|
-
# 编辑配置:
|
|
35
|
+
# 编辑配置: auth_profiles(认证组,key/password 两种模式,可多个)/ target_hosts(IP → 认证组+搜索关键字,多 IP 可共用一组)
|
|
36
36
|
rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
|
|
37
37
|
```
|
|
38
38
|
|
package/src/config.js
CHANGED
|
@@ -33,6 +33,73 @@ function loadConf(confPath) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function cfgError(msg) {
|
|
37
|
+
const err = new Error(`配置错误: ${msg}`);
|
|
38
|
+
err.exitCode = 3;
|
|
39
|
+
return err;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 校验配置 schema(v2:auth_profiles + target_hosts 分组引用)。
|
|
44
|
+
* - auth_profiles: 认证组;auth.mode ∈ {key, password}
|
|
45
|
+
* - key 模式:private_key_path 必填且文件必须存在;private_key_passphrase 可选
|
|
46
|
+
* - password 模式:password 必填非空
|
|
47
|
+
* - target_hosts: {ip: {profile, keyword?}},profile 必须引用已存在的认证组
|
|
48
|
+
*/
|
|
49
|
+
function validateConfig(conf) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const profiles = conf.auth_profiles;
|
|
52
|
+
|
|
53
|
+
if (!profiles || typeof profiles !== "object" || Array.isArray(profiles) || Object.keys(profiles).length === 0) {
|
|
54
|
+
errors.push("缺少 auth_profiles(认证组,见 config.example.json)");
|
|
55
|
+
} else {
|
|
56
|
+
for (const [id, p] of Object.entries(profiles)) {
|
|
57
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) {
|
|
58
|
+
errors.push(`auth_profiles[${id}] 必须是对象 {host, port, username, auth}`) ;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!p.host || typeof p.host !== "string") errors.push(`认证组 [${id}] 缺少 host`);
|
|
62
|
+
if (!p.username || typeof p.username !== "string") errors.push(`认证组 [${id}] 缺少 username`);
|
|
63
|
+
const auth = p.auth;
|
|
64
|
+
if (!auth || typeof auth !== "object" || Array.isArray(auth)) {
|
|
65
|
+
errors.push(`认证组 [${id}] 缺少 auth {mode, ...}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (auth.mode !== "key" && auth.mode !== "password") {
|
|
69
|
+
errors.push(`认证组 [${id}] auth.mode 必须是 "key" 或 "password",当前: ${auth.mode}`);
|
|
70
|
+
} else if (auth.mode === "key") {
|
|
71
|
+
if (!auth.private_key_path || typeof auth.private_key_path !== "string") {
|
|
72
|
+
errors.push(`认证组 [${id}] key 模式缺少 private_key_path`);
|
|
73
|
+
} else if (!fs.existsSync(auth.private_key_path)) {
|
|
74
|
+
errors.push(`认证组 [${id}] 私钥文件不存在: ${auth.private_key_path}`);
|
|
75
|
+
}
|
|
76
|
+
} else if (typeof auth.password !== "string" || !auth.password) {
|
|
77
|
+
errors.push(`认证组 [${id}] password 模式缺少 password`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hosts = conf.target_hosts;
|
|
83
|
+
if (!hosts || typeof hosts !== "object" || Array.isArray(hosts) || Object.keys(hosts).length === 0) {
|
|
84
|
+
errors.push("缺少 target_hosts(目标主机白名单)");
|
|
85
|
+
} else {
|
|
86
|
+
for (const [ip, entry] of Object.entries(hosts)) {
|
|
87
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
88
|
+
errors.push(`target_hosts[${ip}] 必须是对象 {profile, keyword?}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!entry.profile || typeof entry.profile !== "string") {
|
|
92
|
+
errors.push(`target_hosts[${ip}] 缺少 profile(认证组名)`);
|
|
93
|
+
} else if (!profiles || !profiles[entry.profile]) {
|
|
94
|
+
errors.push(`target_hosts[${ip}] 引用不存在的认证组 [${entry.profile}]`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (errors.length) throw cfgError(errors.join(";"));
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
36
103
|
const TOOL_PURPOSE = {
|
|
37
104
|
"sys-info": "系统信息巡检",
|
|
38
105
|
"proc": "进程状态巡检",
|
|
@@ -103,4 +170,4 @@ function audit(conf, tool, host, cmds, ok, note, output, confDir) {
|
|
|
103
170
|
}
|
|
104
171
|
}
|
|
105
172
|
|
|
106
|
-
module.exports = { configDir, configFile, auditFile, loadConf, audit };
|
|
173
|
+
module.exports = { configDir, configFile, auditFile, loadConf, audit, validateConfig };
|
package/src/init.js
CHANGED
|
@@ -40,21 +40,46 @@ function parseTargets(text) {
|
|
|
40
40
|
|
|
41
41
|
function applyAnswers(cfg, answers) {
|
|
42
42
|
const a = answers || {};
|
|
43
|
-
if (a.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
if (a.authProfiles && a.authProfiles.length) {
|
|
44
|
+
const profiles = {};
|
|
45
|
+
for (const p of a.authProfiles) {
|
|
46
|
+
const auth = { mode: p.mode };
|
|
47
|
+
if (p.mode === "password") {
|
|
48
|
+
auth.password = p.password;
|
|
49
|
+
} else {
|
|
50
|
+
auth.private_key_path = p.privateKeyPath;
|
|
51
|
+
if (p.passphrase) auth.private_key_passphrase = p.passphrase;
|
|
52
|
+
}
|
|
53
|
+
profiles[p.name] = {
|
|
54
|
+
host: p.host,
|
|
55
|
+
port: Number(p.port) || 22,
|
|
56
|
+
username: p.username,
|
|
57
|
+
auth,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
cfg.auth_profiles = profiles;
|
|
61
|
+
}
|
|
62
|
+
if (a.targets && Object.keys(a.targets).length) {
|
|
63
|
+
const hosts = {};
|
|
64
|
+
for (const [ip, kw] of Object.entries(a.targets)) {
|
|
65
|
+
hosts[ip] = {
|
|
66
|
+
profile: (a.targetProfileMap && a.targetProfileMap[ip]) || "",
|
|
67
|
+
keyword: kw,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
cfg.target_hosts = hosts;
|
|
71
|
+
}
|
|
48
72
|
return cfg;
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
function defaultAnswersFor(cfg) {
|
|
76
|
+
const first = Object.values(cfg.auth_profiles || {})[0] || {};
|
|
52
77
|
return {
|
|
53
|
-
|
|
54
|
-
jumpPort: cfg.jump_server && cfg.jump_server.port ? String(cfg.jump_server.port) : "22",
|
|
55
|
-
jumpUser: cfg.jump_server && cfg.jump_server.username ? cfg.jump_server.username : "",
|
|
56
|
-
privateKeyPath: cfg.private_key_path && !cfg.private_key_path.includes("YOURNAME") ? cfg.private_key_path : "",
|
|
78
|
+
authProfiles: cfg.auth_profiles || {},
|
|
57
79
|
targetHosts: cfg.target_hosts || {},
|
|
80
|
+
defaultProfile: Object.keys(cfg.auth_profiles || {})[0] || "",
|
|
81
|
+
defaultHost: (first.host && !first.host.includes("YOUR_")) ? first.host : "",
|
|
82
|
+
defaultKey: (first.auth && first.auth.private_key_path && !first.auth.private_key_path.includes("YOURNAME")) ? first.auth.private_key_path : "",
|
|
58
83
|
};
|
|
59
84
|
}
|
|
60
85
|
|
|
@@ -65,27 +90,69 @@ async function collectAnswers(rl, cfg, prompt) {
|
|
|
65
90
|
return ans || def || "";
|
|
66
91
|
};
|
|
67
92
|
const usePrompt = prompt && typeof prompt === "object";
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
93
|
+
const def = defaultAnswersFor(cfg);
|
|
94
|
+
|
|
95
|
+
// 1. 认证组(可多个;多个主机可共用同一组,实现 N:1 分组)
|
|
96
|
+
let authProfiles = [];
|
|
97
|
+
if (usePrompt && prompt.authProfiles) {
|
|
98
|
+
authProfiles = await prompt.authProfiles(ask, cfg);
|
|
99
|
+
} else {
|
|
100
|
+
while (true) {
|
|
101
|
+
const name = await ask("认证组名称(可多个,至少一个;多个主机可共用同一组;留空结束)", "");
|
|
102
|
+
if (!name) {
|
|
103
|
+
if (authProfiles.length === 0) {
|
|
104
|
+
throw new Error("跳过:未填写认证组。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (authProfiles.some((p) => p.name === name)) {
|
|
109
|
+
process.stdout.write(` 认证组 [${name}] 已存在,请换一个名称\n`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const host = await ask(" 堡垒机主机 IP/域名", "");
|
|
113
|
+
if (!host) {
|
|
114
|
+
throw new Error("跳过:未填写堡垒机主机。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
115
|
+
}
|
|
116
|
+
const port = await ask(" 堡垒机端口", "22");
|
|
117
|
+
const username = await ask(" 堡垒机用户名", "");
|
|
118
|
+
const modeAns = await ask(" 认证模式 (1=SSH密钥 2=密码)", "1");
|
|
119
|
+
const mode = modeAns === "2" ? "password" : "key";
|
|
120
|
+
let privateKeyPath = "";
|
|
121
|
+
let passphrase = "";
|
|
122
|
+
let password = "";
|
|
123
|
+
if (mode === "key") {
|
|
124
|
+
privateKeyPath = await ask(" SSH 私钥绝对路径", def.defaultKey || path.join(os.homedir(), ".ssh", "id_rsa"));
|
|
125
|
+
passphrase = await ask(" 私钥口令(无则留空)", "");
|
|
126
|
+
} else {
|
|
127
|
+
password = await ask(" 登录密码", "");
|
|
128
|
+
if (!password) {
|
|
129
|
+
throw new Error("跳过:未填写登录密码。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
authProfiles.push({ name, host, port, username, mode, privateKeyPath, passphrase, password });
|
|
133
|
+
}
|
|
74
134
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
: await ask("堡垒机端口", "22");
|
|
78
|
-
ans.jumpUser = usePrompt && prompt.jumpUser
|
|
79
|
-
? await prompt.jumpUser(ask, cfg)
|
|
80
|
-
: await ask("堡垒机用户名", "");
|
|
81
|
-
ans.privateKeyPath = usePrompt && prompt.privateKeyPath
|
|
82
|
-
? await prompt.privateKeyPath(ask, cfg)
|
|
83
|
-
: await ask("SSH 私钥绝对路径", path.join(os.homedir(), ".ssh", "id_rsa"));
|
|
135
|
+
|
|
136
|
+
// 2. 目标主机白名单(ip=堡垒机搜索关键字)
|
|
84
137
|
const targetText = usePrompt && prompt.targetHosts
|
|
85
138
|
? await prompt.targetHosts(ask, cfg)
|
|
86
139
|
: await ask("目标主机(每行 ip=堡垒机搜索关键字,空行结束)", "");
|
|
87
|
-
|
|
88
|
-
|
|
140
|
+
const targets = parseTargets(targetText);
|
|
141
|
+
|
|
142
|
+
// 3. 每台主机归属的认证组(默认第一个组)
|
|
143
|
+
const defaultProfile = authProfiles[0] && authProfiles[0].name;
|
|
144
|
+
if (Object.keys(targets).length && !defaultProfile) {
|
|
145
|
+
throw new Error("配置错误: 未配置认证组,无法关联目标主机");
|
|
146
|
+
}
|
|
147
|
+
const targetProfileMap = {};
|
|
148
|
+
for (const ip of Object.keys(targets)) {
|
|
149
|
+
const p = usePrompt && prompt.targetProfile
|
|
150
|
+
? await prompt.targetProfile(ask, cfg, ip)
|
|
151
|
+
: await ask(` 主机 ${ip} 使用的认证组`, defaultProfile);
|
|
152
|
+
targetProfileMap[ip] = p || defaultProfile;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { authProfiles, targets, targetProfileMap };
|
|
89
156
|
}
|
|
90
157
|
|
|
91
158
|
async function runInteractive({ input = process.stdin, output = process.stdout, packageRoot = PACKAGE_ROOT, prompt, installSkills = true, rlFactory } = {}) {
|
package/src/jumpshell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const { Client } = require("ssh2");
|
|
4
5
|
|
|
5
6
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
7
|
|
|
@@ -17,46 +18,120 @@ const ERR_PATTERNS = [
|
|
|
17
18
|
/no route to host/i,
|
|
18
19
|
];
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// ssh2 默认算法集已兼容 JumpServer 常见旧算法(ssh-rsa hostkey、diffie-hellman-group14-sha1 等),
|
|
22
|
+
// 无需额外放宽;hostVerifier 恒真等价于原 StrictHostKeyChecking=no。
|
|
23
|
+
const CONNECT_OPTIONS = {
|
|
24
|
+
readyTimeout: 20000,
|
|
25
|
+
keepaliveInterval: 10000,
|
|
26
|
+
hostVerifier: () => true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 从配置解析某目标主机所属的认证组(auth_profiles 条目)。
|
|
31
|
+
* target_hosts[host] = { profile, keyword };keyword 缺省回退为 host 本身。
|
|
32
|
+
*/
|
|
33
|
+
function resolveHostEntry(conf, host) {
|
|
34
|
+
const entry = conf.target_hosts && conf.target_hosts[host];
|
|
35
|
+
if (!entry) {
|
|
36
|
+
throw new Error(`配置错误: 目标主机 [${host}] 未在 target_hosts 注册`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof entry !== "object" || Array.isArray(entry)) {
|
|
39
|
+
throw new Error(`配置错误: target_hosts[${host}] 必须是对象 {profile, keyword}`);
|
|
40
|
+
}
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveProfile(conf, host) {
|
|
45
|
+
const entry = resolveHostEntry(conf, host);
|
|
46
|
+
const profile = conf.auth_profiles && conf.auth_profiles[entry.profile];
|
|
47
|
+
if (!profile) {
|
|
48
|
+
throw new Error(`配置错误: 目标主机 [${host}] 引用的认证组 [${entry.profile}] 不存在(auth_profiles)`);
|
|
49
|
+
}
|
|
50
|
+
return profile;
|
|
51
|
+
}
|
|
25
52
|
|
|
26
53
|
/**
|
|
27
|
-
*
|
|
54
|
+
* 把 ssh2 连接错误映射为友好提示;原始消息中的密码类内容一律脱敏,不泄露凭据。
|
|
55
|
+
*/
|
|
56
|
+
function mapConnError(e) {
|
|
57
|
+
const msg = String((e && e.message) || e).replace(
|
|
58
|
+
/(password|passwd)[=:\s][^\s,;]+/gi,
|
|
59
|
+
"$1=***"
|
|
60
|
+
);
|
|
61
|
+
if (/authentication|permission denied/i.test(msg)) {
|
|
62
|
+
return new Error(`SSH 认证失败: ${msg}(请检查 auth_profiles 的账号/密码/私钥)`);
|
|
63
|
+
}
|
|
64
|
+
if (/connect/i.test(msg)) {
|
|
65
|
+
return new Error(`SSH 连接失败: ${msg}`);
|
|
66
|
+
}
|
|
67
|
+
return new Error(`SSH 错误: ${msg}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 通过 ssh2 连接 JumpServer 堡垒机菜单,登录目标资产并执行命令。
|
|
28
72
|
* 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
|
|
29
73
|
*/
|
|
30
74
|
class JumpShell {
|
|
31
75
|
constructor(conf, host) {
|
|
32
|
-
this.searchKeyword = conf.target_hosts[host];
|
|
33
76
|
this.host = host;
|
|
34
77
|
this.conf = conf;
|
|
35
|
-
this.
|
|
78
|
+
this.entry = resolveHostEntry(conf, host);
|
|
79
|
+
this.profile = resolveProfile(conf, host);
|
|
80
|
+
this.searchKeyword = this.entry.keyword || host;
|
|
81
|
+
this.conn = null;
|
|
82
|
+
this.stream = null;
|
|
36
83
|
this.out = "";
|
|
37
84
|
this.err = "";
|
|
38
85
|
this.terminated = false;
|
|
39
86
|
}
|
|
40
87
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
/** 建立 ssh2 连接并打开伪终端;返回 Promise。 */
|
|
89
|
+
connect(timeoutSecs = 20) {
|
|
90
|
+
const p = this.profile;
|
|
91
|
+
const cfg = {
|
|
92
|
+
host: p.host,
|
|
93
|
+
port: p.port || 22,
|
|
94
|
+
username: p.username,
|
|
95
|
+
...CONNECT_OPTIONS,
|
|
96
|
+
};
|
|
97
|
+
if (p.auth.mode === "password") {
|
|
98
|
+
cfg.password = p.auth.password;
|
|
99
|
+
} else {
|
|
100
|
+
cfg.privateKey = fs.readFileSync(p.auth.private_key_path);
|
|
101
|
+
if (p.auth.private_key_passphrase) cfg.passphrase = p.auth.private_key_passphrase;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const conn = new Client();
|
|
106
|
+
this.conn = conn;
|
|
107
|
+
const fail = (e) => {
|
|
108
|
+
try { conn.end(); } catch (_) {}
|
|
109
|
+
reject(mapConnError(e));
|
|
110
|
+
};
|
|
111
|
+
conn.on("error", fail);
|
|
112
|
+
conn.on("ready", () => {
|
|
113
|
+
conn.shell({ term: "xterm", rows: 30, cols: 120 }, (err, stream) => {
|
|
114
|
+
if (err) return fail(err);
|
|
115
|
+
this.stream = stream;
|
|
116
|
+
stream.on("data", (d) => { this.out += d.toString("utf8"); });
|
|
117
|
+
stream.on("close", () => { this.terminated = true; });
|
|
118
|
+
if (stream.stderr) {
|
|
119
|
+
stream.stderr.on("data", (d) => { this.err += d.toString("utf8"); });
|
|
120
|
+
}
|
|
121
|
+
resolve();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
conn.connect(cfg);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
fail(e);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
55
130
|
}
|
|
56
131
|
|
|
57
132
|
send(s) {
|
|
58
|
-
if (this.
|
|
59
|
-
this.
|
|
133
|
+
if (this.stream && !this.stream.destroyed) {
|
|
134
|
+
this.stream.write(s + "\r");
|
|
60
135
|
}
|
|
61
136
|
}
|
|
62
137
|
|
|
@@ -129,11 +204,8 @@ class JumpShell {
|
|
|
129
204
|
|
|
130
205
|
close() {
|
|
131
206
|
this.terminated = true;
|
|
132
|
-
try { this.
|
|
133
|
-
try { if (this.
|
|
134
|
-
setTimeout(() => {
|
|
135
|
-
try { if (this.proc) this.proc.kill(); } catch {}
|
|
136
|
-
}, 1000);
|
|
207
|
+
try { if (this.stream) this.stream.end(); } catch (_) {}
|
|
208
|
+
try { if (this.conn) this.conn.end(); } catch (_) {}
|
|
137
209
|
}
|
|
138
210
|
}
|
|
139
211
|
|
|
@@ -143,4 +215,4 @@ function cleanAnsi(t) {
|
|
|
143
215
|
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // CSI 序列(颜色/光标)
|
|
144
216
|
}
|
|
145
217
|
|
|
146
|
-
module.exports = { JumpShell, cleanAnsi, sleep, PROMPT_RE };
|
|
218
|
+
module.exports = { JumpShell, cleanAnsi, sleep, PROMPT_RE, resolveHostEntry, resolveProfile, mapConnError };
|