@zicolasjac-ai/remote-readonly-ssh 1.1.7 → 1.2.1

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 CHANGED
@@ -7,8 +7,9 @@
7
7
  ## 为什么需要它
8
8
 
9
9
  - **技术强制只读**:命令前缀白名单 + 禁止模式 + 参数防注入 + 目标主机白名单,多层校验。
10
+ - **主机密钥防中间人**:TOFU 校验——首次连接自动记录堡垒机公钥指纹(`~/.rrs/known_hosts.json`),之后每次连接比对,不一致直接拒绝。
10
11
  - **审计留痕**:每次调用(含被拒绝的记录)写入本地审计日志,可随时审查。
11
- - **零依赖**:只依赖系统自带 `ssh` 客户端,无 npm 依赖树。
12
+ - **极简依赖**:连接层用纯 JS 的 `ssh2`(无原生编译),认证组支持 **SSH 私钥 / 密码** 两种模式,可按 IP 分组配置(N 台主机共用一组凭据),跨平台行为一致。
12
13
  - **AI Agent 友好**:附带 SKILL.md 可一键安装到 opencode / Claude / Cursor / Windsurf / Codex / OpenHands 等 AI 助手的 skills 目录。
13
14
 
14
15
  ## 开始使用
@@ -22,8 +23,8 @@ npx -y @zicolasjac-ai/remote-readonly-ssh@latest init
22
23
  npx 会自动拉取最新版本、进入初始化向导,依次询问:
23
24
 
24
25
  1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
25
- 2. 堡垒机主机 / 端口 / 用户名 / SSH 私钥路径
26
- 3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`)
26
+ 2. **认证组**:堡垒机主机 / 端口 / 用户名 / 认证模式(SSH 密钥或密码)/ 凭据;可配置多个认证组
27
+ 3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`),并为每台主机指定归属的认证组(多台主机可共用同一组)
27
28
  4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI 助手
28
29
 
29
30
  向导结束后 `~/.rrs/config.json` 已就位、`~/.rrs/audit.log` 已建好、AI skill 已分发。直接试跑:
@@ -46,7 +47,7 @@ rrs sys-info --json # 直接用 rrs 前缀
46
47
  >
47
48
  > 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
48
49
 
49
- 要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
50
+ 要求 Node.js >= 18(连接层走内置 `ssh2`,无需系统 OpenSSH 客户端)。
50
51
 
51
52
  ## 用法
52
53
 
@@ -74,16 +75,18 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
74
75
 
75
76
  | 键 | 说明 |
76
77
  |-----|------|
77
- | `jump_server` | 堡垒机地址 / 端口 / 用户名 |
78
- | `ssh_options` | 附加 ssh 参数(默认 `StrictHostKeyChecking=no`、兼容旧堡垒机的 ssh-rsa 算法开关,可按需调整) |
78
+ | `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`list-tools` 只显示模式与主机,不显示任何凭据 |
79
+ | `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP)。**多台主机可共用同一认证组(N:1)** |
79
80
  | `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
81
  | `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
83
82
  | `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
84
83
  | `allowed_path_prefixes` | 允许读取的路径前缀 |
85
84
  | `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
86
85
 
86
+ > **凭据安全提示**:密码为明文存放于本机 `config.json`(与私钥文件同级信任,仅本机可读)。Windows 建议收紧 ACL,Linux/macOS 建议 `chmod 600 ~/.rrs/config.json`。
87
+ >
88
+ > **主机密钥校验**:首次连接堡垒机时自动记录其公钥指纹到 `~/.rrs/known_hosts.json`;此后密钥变化一律拒绝连接(防中间人)。若堡垒机确实已更换,删除该文件中对应条目重试即可重新记录。`init` 向导中密码输入**静音不回显**。
89
+
87
90
  审计日志:`~/.rrs/audit.log`
88
91
 
89
92
  ## Agent Skills
@@ -132,6 +135,8 @@ npm test # 运行单元测试(node --test)
132
135
  - **防绕过加固**:路径 `..` 穿越规范化、通配符 glob(`/etc/*/shadow`)、反斜杠转义、命令替换(`$()`、反引号)、变量展开(`$HOME`)、波浪号(`~`)、分隔符(`;` `&` `&&`)、换行注入等攻击面全部封堵;`hostname <name>`、`date -s` 等状态变更命令拒绝。
133
136
  - **参数防注入**:`proc`/`ports`/`bin-version` 参数做严格字符集校验。
134
137
  - **主机白名单**:未登记 IP 一律拒绝。
138
+ - **主机密钥 TOFU**:堡垒机公钥指纹首见记录(`~/.rrs/known_hosts.json`)、每次连接比对,变化即拒绝(防中间人窃取凭据/会话);连接错误消息中的凭据类内容自动脱敏。
139
+ - **凭据不落命令行**:认证走 ssh2 库内存参数,密码/私钥不出现在进程参数与环境变量中;审计日志只记命令,不含凭据。
135
140
  - **无写原语**:工具集中不存在任何写操作路径;写操作由人工执行,本工具只读。
136
141
 
137
142
  **已知边界**(设计取舍):
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,
@@ -126,7 +126,10 @@ function runSetup(opts) {
126
126
 
127
127
  function initConfig() {
128
128
  if (process.stdin.isTTY && process.stdout.isTTY) {
129
- return initWizard.runInteractive();
129
+ return initWizard.runInteractive().catch((e) => {
130
+ console.error(`[init] ${e.message}`);
131
+ process.exit(1);
132
+ });
130
133
  }
131
134
  const dir = configDir();
132
135
  const dest = path.join(dir, "config.json");
@@ -137,7 +140,7 @@ function initConfig() {
137
140
  const tpl = fs.readFileSync(path.join(PACKAGE_ROOT, "config.example.json"), "utf8");
138
141
  fs.writeFileSync(dest, tpl, "utf8");
139
142
  console.log(`已生成配置模板: ${dest}`);
140
- console.log("请填写 jump_server(跳板机)、private_key_path(私钥)、target_hosts(目标主机)后使用。");
143
+ console.log("请填写 auth_profiles(认证组:SSH 私钥或密码)、target_hosts(目标主机)后使用。");
141
144
  }
142
145
 
143
146
  function buildCmds(tool, arg, conf) {
@@ -180,7 +183,9 @@ function listTools(conf) {
180
183
  "exec-only <cmd>": "通用只读命令(白名单校验)",
181
184
  };
182
185
  for (const [k, v] of Object.entries(tools)) console.log(` ${k.padEnd(20)} ${v}`);
183
- console.log(`\n允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
186
+ const profiles = conf.auth_profiles || {};
187
+ console.log(`\n认证组: ${Object.entries(profiles).map(([id, p]) => `${id}(${p.auth.mode}@${p.host})`).join(", ") || "(无)"}`);
188
+ console.log(`允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
184
189
  console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
185
190
  console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
186
191
  console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
@@ -211,6 +216,13 @@ async function main() {
211
216
  process.exit(e.exitCode || 1);
212
217
  }
213
218
 
219
+ try {
220
+ validateConfig(conf);
221
+ } catch (e) {
222
+ emitResult(opts, { ok: false, error: e.message, kind: "错误" });
223
+ process.exit(e.exitCode || 1);
224
+ }
225
+
214
226
  if (opts.tool === "list-tools") { listTools(conf); return; }
215
227
 
216
228
  let host, cmds;
@@ -225,7 +237,7 @@ async function main() {
225
237
 
226
238
  const shell = new JumpShell(conf, host);
227
239
  try {
228
- shell.connect();
240
+ await shell.connect();
229
241
  await shell.login();
230
242
  const out = await shell.runCmds(cmds);
231
243
  const cleaned = cleanAnsi(out);
@@ -1,23 +1,33 @@
1
1
  {
2
- "jump_server": {
3
- "host": "YOUR_BASTION_IP",
4
- "port": 2222,
5
- "username": "YOUR_USERNAME"
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.1.7",
4
- "description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
3
+ "version": "1.2.1",
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
- # 编辑配置: jump_server / private_key_path / target_hosts
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,76 @@ 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
+ if (p.port !== undefined && (!Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
64
+ errors.push(`认证组 [${id}] port 必须是 1-65535 的整数,当前: ${p.port}`);
65
+ }
66
+ const auth = p.auth;
67
+ if (!auth || typeof auth !== "object" || Array.isArray(auth)) {
68
+ errors.push(`认证组 [${id}] 缺少 auth {mode, ...}`);
69
+ continue;
70
+ }
71
+ if (auth.mode !== "key" && auth.mode !== "password") {
72
+ errors.push(`认证组 [${id}] auth.mode 必须是 "key" 或 "password",当前: ${auth.mode}`);
73
+ } else if (auth.mode === "key") {
74
+ if (!auth.private_key_path || typeof auth.private_key_path !== "string") {
75
+ errors.push(`认证组 [${id}] key 模式缺少 private_key_path`);
76
+ } else if (!fs.existsSync(auth.private_key_path)) {
77
+ errors.push(`认证组 [${id}] 私钥文件不存在: ${auth.private_key_path}`);
78
+ }
79
+ } else if (typeof auth.password !== "string" || !auth.password) {
80
+ errors.push(`认证组 [${id}] password 模式缺少 password`);
81
+ }
82
+ }
83
+ }
84
+
85
+ const hosts = conf.target_hosts;
86
+ if (!hosts || typeof hosts !== "object" || Array.isArray(hosts) || Object.keys(hosts).length === 0) {
87
+ errors.push("缺少 target_hosts(目标主机白名单)");
88
+ } else {
89
+ for (const [ip, entry] of Object.entries(hosts)) {
90
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
91
+ errors.push(`target_hosts[${ip}] 必须是对象 {profile, keyword?}`);
92
+ continue;
93
+ }
94
+ if (!entry.profile || typeof entry.profile !== "string") {
95
+ errors.push(`target_hosts[${ip}] 缺少 profile(认证组名)`);
96
+ } else if (!profiles || !profiles[entry.profile]) {
97
+ errors.push(`target_hosts[${ip}] 引用不存在的认证组 [${entry.profile}]`);
98
+ }
99
+ }
100
+ }
101
+
102
+ if (errors.length) throw cfgError(errors.join(";"));
103
+ return true;
104
+ }
105
+
36
106
  const TOOL_PURPOSE = {
37
107
  "sys-info": "系统信息巡检",
38
108
  "proc": "进程状态巡检",
@@ -103,4 +173,4 @@ function audit(conf, tool, host, cmds, ok, note, output, confDir) {
103
173
  }
104
174
  }
105
175
 
106
- module.exports = { configDir, configFile, auditFile, loadConf, audit };
176
+ module.exports = { configDir, configFile, auditFile, loadConf, audit, validateConfig };
package/src/init.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
+ const { Writable } = require("node:stream");
6
7
  const readline = require("node:readline/promises");
7
8
  const setupSkill = require("./setup");
8
9
 
@@ -40,24 +41,88 @@ function parseTargets(text) {
40
41
 
41
42
  function applyAnswers(cfg, answers) {
42
43
  const a = answers || {};
43
- if (a.jumpHost) cfg.jump_server.host = a.jumpHost;
44
- if (a.jumpPort) cfg.jump_server.port = Number(a.jumpPort) || cfg.jump_server.port;
45
- if (a.jumpUser) cfg.jump_server.username = a.jumpUser;
46
- if (a.privateKeyPath) cfg.private_key_path = a.privateKeyPath;
47
- if (a.targetHosts && Object.keys(a.targetHosts).length) cfg.target_hosts = a.targetHosts;
44
+ if (a.authProfiles && a.authProfiles.length) {
45
+ const profiles = {};
46
+ for (const p of a.authProfiles) {
47
+ const auth = { mode: p.mode };
48
+ if (p.mode === "password") {
49
+ auth.password = p.password;
50
+ } else {
51
+ auth.private_key_path = p.privateKeyPath;
52
+ if (p.passphrase) auth.private_key_passphrase = p.passphrase;
53
+ }
54
+ profiles[p.name] = {
55
+ host: p.host,
56
+ port: Number(p.port) || 22,
57
+ username: p.username,
58
+ auth,
59
+ };
60
+ }
61
+ cfg.auth_profiles = profiles;
62
+ }
63
+ if (a.targets && Object.keys(a.targets).length) {
64
+ const hosts = {};
65
+ for (const [ip, kw] of Object.entries(a.targets)) {
66
+ hosts[ip] = {
67
+ profile: (a.targetProfileMap && a.targetProfileMap[ip]) || "",
68
+ keyword: kw,
69
+ };
70
+ }
71
+ cfg.target_hosts = hosts;
72
+ }
48
73
  return cfg;
49
74
  }
50
75
 
51
76
  function defaultAnswersFor(cfg) {
77
+ const first = Object.values(cfg.auth_profiles || {})[0] || {};
52
78
  return {
53
- jumpHost: cfg.jump_server && cfg.jump_server.host ? "" : "",
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 : "",
79
+ authProfiles: cfg.auth_profiles || {},
57
80
  targetHosts: cfg.target_hosts || {},
81
+ defaultProfile: Object.keys(cfg.auth_profiles || {})[0] || "",
82
+ defaultHost: (first.host && !first.host.includes("YOUR_")) ? first.host : "",
83
+ defaultKey: (first.auth && first.auth.private_key_path && !first.auth.private_key_path.includes("YOURNAME")) ? first.auth.private_key_path : "",
58
84
  };
59
85
  }
60
86
 
87
+ /**
88
+ * 静音输入(TTY 下不回显,防肩窥)。
89
+ * 原理:临时把 readline 的 output 替换为吞字节流(屏蔽 readline 的按键回显),
90
+ * 自行逐字节收集(支持退格);结束恢复原 output。
91
+ * 非标准环境(测试 mock / 管道,无 TTY)回退为普通 question(明文回显)。
92
+ */
93
+ function askSecret(rl, q) {
94
+ const input = rl.input;
95
+ const output = rl.output;
96
+ if (!input || !output || input.isTTY !== true || typeof input.setRawMode !== "function") {
97
+ return Promise.resolve(rl.question(`${q}: `));
98
+ }
99
+ return new Promise((resolve, reject) => {
100
+ const silent = new Writable({
101
+ write(_chunk, _enc, cb) { cb(); },
102
+ });
103
+ rl.output = silent;
104
+ const wasRaw = input.isRaw;
105
+ input.setRawMode(true);
106
+ output.write(`${q}: `);
107
+ let buf = "";
108
+ const finish = (fn) => {
109
+ input.removeListener("data", onData);
110
+ try { input.setRawMode(wasRaw); } catch (_) {}
111
+ rl.output = output;
112
+ output.write("\n");
113
+ fn();
114
+ };
115
+ const onData = (ch) => {
116
+ const s = ch.toString("utf8");
117
+ if (s === "\r" || s === "\n") return finish(() => resolve(buf));
118
+ if (s === "\u0003") return finish(() => reject(new Error("已取消输入")));
119
+ if (s === "\u007f" || s === "\b") { buf = buf.slice(0, -1); return; }
120
+ if (s >= " ") buf += s;
121
+ };
122
+ input.on("data", onData);
123
+ });
124
+ }
125
+
61
126
  async function collectAnswers(rl, cfg, prompt) {
62
127
  const ask = async (q, def) => {
63
128
  const label = def ? `${q} [${def}]` : q;
@@ -65,27 +130,69 @@ async function collectAnswers(rl, cfg, prompt) {
65
130
  return ans || def || "";
66
131
  };
67
132
  const usePrompt = prompt && typeof prompt === "object";
68
- const ans = {};
69
- ans.jumpHost = usePrompt && prompt.jumpHost
70
- ? await prompt.jumpHost(ask, cfg)
71
- : await ask("堡垒机主机 IP / 域名", "");
72
- if (!ans.jumpHost) {
73
- throw new Error("跳过:未填写堡垒机主机。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
133
+ const def = defaultAnswersFor(cfg);
134
+
135
+ // 1. 认证组(可多个;多个主机可共用同一组,实现 N:1 分组)
136
+ let authProfiles = [];
137
+ if (usePrompt && prompt.authProfiles) {
138
+ authProfiles = await prompt.authProfiles(ask, cfg);
139
+ } else {
140
+ while (true) {
141
+ const name = await ask("认证组名称(可多个,至少一个;多个主机可共用同一组;留空结束)", "");
142
+ if (!name) {
143
+ if (authProfiles.length === 0) {
144
+ throw new Error("跳过:未填写认证组。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
145
+ }
146
+ break;
147
+ }
148
+ if (authProfiles.some((p) => p.name === name)) {
149
+ process.stdout.write(` 认证组 [${name}] 已存在,请换一个名称\n`);
150
+ continue;
151
+ }
152
+ const host = await ask(" 堡垒机主机 IP/域名", "");
153
+ if (!host) {
154
+ throw new Error("跳过:未填写堡垒机主机。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
155
+ }
156
+ const port = await ask(" 堡垒机端口", "22");
157
+ const username = await ask(" 堡垒机用户名", "");
158
+ const modeAns = await ask(" 认证模式 (1=SSH密钥 2=密码)", "1");
159
+ const mode = modeAns === "2" ? "password" : "key";
160
+ let privateKeyPath = "";
161
+ let passphrase = "";
162
+ let password = "";
163
+ if (mode === "key") {
164
+ privateKeyPath = await ask(" SSH 私钥绝对路径", def.defaultKey || path.join(os.homedir(), ".ssh", "id_rsa"));
165
+ passphrase = (await askSecret(rl, " 私钥口令(无则留空)")).trim();
166
+ } else {
167
+ password = (await askSecret(rl, " 登录密码")).trim();
168
+ if (!password) {
169
+ throw new Error("跳过:未填写登录密码。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
170
+ }
171
+ }
172
+ authProfiles.push({ name, host, port, username, mode, privateKeyPath, passphrase, password });
173
+ }
74
174
  }
75
- ans.jumpPort = usePrompt && prompt.jumpPort
76
- ? await prompt.jumpPort(ask, cfg)
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"));
175
+
176
+ // 2. 目标主机白名单(ip=堡垒机搜索关键字)
84
177
  const targetText = usePrompt && prompt.targetHosts
85
178
  ? await prompt.targetHosts(ask, cfg)
86
179
  : await ask("目标主机(每行 ip=堡垒机搜索关键字,空行结束)", "");
87
- ans.targetHosts = parseTargets(targetText);
88
- return ans;
180
+ const targets = parseTargets(targetText);
181
+
182
+ // 3. 每台主机归属的认证组(默认第一个组)
183
+ const defaultProfile = authProfiles[0] && authProfiles[0].name;
184
+ if (Object.keys(targets).length && !defaultProfile) {
185
+ throw new Error("配置错误: 未配置认证组,无法关联目标主机");
186
+ }
187
+ const targetProfileMap = {};
188
+ for (const ip of Object.keys(targets)) {
189
+ const p = usePrompt && prompt.targetProfile
190
+ ? await prompt.targetProfile(ask, cfg, ip)
191
+ : await ask(` 主机 ${ip} 使用的认证组`, defaultProfile);
192
+ targetProfileMap[ip] = p || defaultProfile;
193
+ }
194
+
195
+ return { authProfiles, targets, targetProfileMap };
89
196
  }
90
197
 
91
198
  async function runInteractive({ input = process.stdin, output = process.stdout, packageRoot = PACKAGE_ROOT, prompt, installSkills = true, rlFactory } = {}) {
package/src/jumpshell.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
 
3
- const { spawn } = require("child_process");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+ const { Client } = require("ssh2");
7
+ const { configDir } = require("./config");
4
8
 
5
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
10
 
@@ -17,46 +21,187 @@ const ERR_PATTERNS = [
17
21
  /no route to host/i,
18
22
  ];
19
23
 
20
- const DEFAULT_SSH_OPTIONS = [
21
- "-o", "StrictHostKeyChecking=no",
22
- "-o", "HostKeyAlgorithms=+ssh-rsa",
23
- "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
24
- ];
24
+ // ssh2 默认算法集已兼容 JumpServer 常见旧算法(ssh-rsa hostkey、diffie-hellman-group14-sha1 等),
25
+ // 客户端列表强算法优先,无需额外放宽。
26
+ const CONNECT_OPTIONS = {
27
+ readyTimeout: 20000,
28
+ keepaliveInterval: 10000,
29
+ };
30
+
31
+ // ==== 主机密钥 TOFU(trust-on-first-use)====
32
+ // 等价并强于原 ssh -o StrictHostKeyChecking=no:首次连接记录堡垒机公钥指纹到
33
+ // ~/.rrs/known_hosts.json,之后每次连接比对,不一致则拒绝(防中间人长期冒充)。
34
+
35
+ function knownHostsPath(dir) {
36
+ return path.join(dir || configDir(), "known_hosts.json");
37
+ }
38
+
39
+ function loadKnownHosts(dir) {
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(knownHostsPath(dir), "utf8"));
42
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
43
+ } catch (_) {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ function saveKnownHosts(store, dir) {
49
+ const target = knownHostsPath(dir);
50
+ fs.mkdirSync(path.dirname(target), { recursive: true });
51
+ fs.writeFileSync(target, JSON.stringify(store, null, 2), "utf8");
52
+ }
53
+
54
+ function hostKeyFingerprint(hostkey) {
55
+ return "sha256:" + crypto.createHash("sha256").update(hostkey).digest("base64");
56
+ }
57
+
58
+ /**
59
+ * 纯函数:比对主机密钥。首见返回 isNew=true(调用方负责持久化),
60
+ * 已知且匹配返回 ok=true;已知但不匹配返回 ok=false(应拒绝连接)。
61
+ */
62
+ function checkHostKey(store, endpointId, hostkey) {
63
+ const fp = hostKeyFingerprint(hostkey);
64
+ if (!store[endpointId]) return { ok: true, fp, isNew: true };
65
+ return { ok: store[endpointId] === fp, fp, isNew: false, known: store[endpointId] };
66
+ }
25
67
 
26
68
  /**
27
- * 通过系统 ssh 驱动 JumpServer 堡垒机菜单,登录目标资产并执行命令。
69
+ * 从配置解析某目标主机所属的认证组(auth_profiles 条目)。
70
+ * target_hosts[host] = { profile, keyword };keyword 缺省回退为 host 本身。
71
+ */
72
+ function resolveHostEntry(conf, host) {
73
+ const entry = conf.target_hosts && conf.target_hosts[host];
74
+ if (!entry) {
75
+ throw new Error(`配置错误: 目标主机 [${host}] 未在 target_hosts 注册`);
76
+ }
77
+ if (typeof entry !== "object" || Array.isArray(entry)) {
78
+ throw new Error(`配置错误: target_hosts[${host}] 必须是对象 {profile, keyword}`);
79
+ }
80
+ return entry;
81
+ }
82
+
83
+ function resolveProfile(conf, host) {
84
+ const entry = resolveHostEntry(conf, host);
85
+ const profile = conf.auth_profiles && conf.auth_profiles[entry.profile];
86
+ if (!profile) {
87
+ throw new Error(`配置错误: 目标主机 [${host}] 引用的认证组 [${entry.profile}] 不存在(auth_profiles)`);
88
+ }
89
+ return profile;
90
+ }
91
+
92
+ /**
93
+ * 把 ssh2 连接错误映射为友好提示;原始消息中的密码类内容一律脱敏,不泄露凭据。
94
+ */
95
+ function mapConnError(e) {
96
+ const msg = String((e && e.message) || e).replace(
97
+ /(password|passwd)[=:\s][^\s,;]+/gi,
98
+ "$1=***"
99
+ );
100
+ if (/host key verification|host denied/i.test(msg)) {
101
+ return new Error(
102
+ "堡垒机主机密钥与首次记录不一致(已拒绝,防中间人)。" +
103
+ "若堡垒机确实已更换,请删除 ~/.rrs/known_hosts.json 中对应条目后重试"
104
+ );
105
+ }
106
+ if (/authentication|permission denied/i.test(msg)) {
107
+ return new Error(`SSH 认证失败: ${msg}(请检查 auth_profiles 的账号/密码/私钥)`);
108
+ }
109
+ if (/connect/i.test(msg)) {
110
+ return new Error(`SSH 连接失败: ${msg}`);
111
+ }
112
+ return new Error(`SSH 错误: ${msg}`);
113
+ }
114
+
115
+ /**
116
+ * 通过 ssh2 连接 JumpServer 堡垒机菜单,登录目标资产并执行命令。
28
117
  * 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
29
118
  */
30
119
  class JumpShell {
31
- constructor(conf, host) {
32
- this.searchKeyword = conf.target_hosts[host];
120
+ constructor(conf, host, opts = {}) {
33
121
  this.host = host;
34
122
  this.conf = conf;
35
- this.proc = null;
123
+ this.entry = resolveHostEntry(conf, host);
124
+ this.profile = resolveProfile(conf, host);
125
+ this.searchKeyword = this.entry.keyword || host;
126
+ this.knownHostsDir = opts.knownHostsDir; // 测试注入用;缺省 ~/.rrs
127
+ this.conn = null;
128
+ this.stream = null;
36
129
  this.out = "";
37
130
  this.err = "";
38
131
  this.terminated = false;
39
132
  }
40
133
 
41
- connect() {
42
- const js = this.conf.jump_server;
43
- const opts = this.conf.ssh_options && this.conf.ssh_options.length
44
- ? this.conf.ssh_options
45
- : DEFAULT_SSH_OPTIONS;
46
- const args = [
47
- "-i", this.conf.private_key_path,
48
- "-p", String(js.port),
49
- ...opts,
50
- "-tt", `${js.username}@${js.host}`,
51
- ];
52
- this.proc = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
53
- this.proc.stdout.on("data", (d) => { this.out += d.toString("utf8"); });
54
- this.proc.stderr.on("data", (d) => { this.err += d.toString("utf8"); });
134
+ /** 建立 ssh2 连接并打开伪终端;返回 Promise。 */
135
+ connect(timeoutSecs = 20) {
136
+ const p = this.profile;
137
+ const cfg = {
138
+ host: p.host,
139
+ port: p.port || 22,
140
+ username: p.username,
141
+ ...CONNECT_OPTIONS,
142
+ };
143
+ if (p.auth.mode === "password") {
144
+ cfg.password = p.auth.password;
145
+ // 兼容强制 keyboard-interactive 的堡垒机:用同一密码自动应答
146
+ cfg.tryKeyboard = true;
147
+ } else {
148
+ cfg.privateKey = fs.readFileSync(p.auth.private_key_path);
149
+ if (p.auth.private_key_passphrase) cfg.passphrase = p.auth.private_key_passphrase;
150
+ }
151
+
152
+ // 主机密钥 TOFU 校验:首见记录,不一致拒绝。
153
+ // ssh2 用户回调签名固定为 hostVerifier(key, verify)(key 为服务器公钥 Buffer)。
154
+ cfg.hostVerifier = (hostkey, verify) => {
155
+ try {
156
+ const store = loadKnownHosts(this.knownHostsDir);
157
+ const endpointId = `${cfg.host}:${cfg.port}`;
158
+ const r = checkHostKey(store, endpointId, hostkey);
159
+ if (r.isNew) {
160
+ store[endpointId] = r.fp;
161
+ saveKnownHosts(store, this.knownHostsDir);
162
+ }
163
+ verify(r.ok);
164
+ } catch (_) {
165
+ verify(false);
166
+ }
167
+ };
168
+
169
+ return new Promise((resolve, reject) => {
170
+ const conn = new Client();
171
+ this.conn = conn;
172
+ const fail = (e) => {
173
+ try { conn.end(); } catch (_) {}
174
+ reject(mapConnError(e));
175
+ };
176
+ conn.on("error", fail);
177
+ if (p.auth.mode === "password") {
178
+ conn.on("keyboard-interactive", (_name, _instr, _lang, _prompts, finish) => {
179
+ try { finish([cfg.password || ""]); } catch (_) {}
180
+ });
181
+ }
182
+ conn.on("ready", () => {
183
+ conn.shell({ term: "xterm", rows: 30, cols: 120 }, (err, stream) => {
184
+ if (err) return fail(err);
185
+ this.stream = stream;
186
+ stream.on("data", (d) => { this.out += d.toString("utf8"); });
187
+ stream.on("close", () => { this.terminated = true; });
188
+ if (stream.stderr) {
189
+ stream.stderr.on("data", (d) => { this.err += d.toString("utf8"); });
190
+ }
191
+ resolve();
192
+ });
193
+ });
194
+ try {
195
+ conn.connect(cfg);
196
+ } catch (e) {
197
+ fail(e);
198
+ }
199
+ });
55
200
  }
56
201
 
57
202
  send(s) {
58
- if (this.proc && this.proc.stdin && !this.proc.stdin.destroyed) {
59
- this.proc.stdin.write(s + "\r");
203
+ if (this.stream && !this.stream.destroyed) {
204
+ this.stream.write(s + "\r");
60
205
  }
61
206
  }
62
207
 
@@ -129,11 +274,8 @@ class JumpShell {
129
274
 
130
275
  close() {
131
276
  this.terminated = true;
132
- try { this.send("exit"); } catch {}
133
- try { if (this.proc && this.proc.stdin) this.proc.stdin.end(); } catch {}
134
- setTimeout(() => {
135
- try { if (this.proc) this.proc.kill(); } catch {}
136
- }, 1000);
277
+ try { if (this.stream) this.stream.end(); } catch (_) {}
278
+ try { if (this.conn) this.conn.end(); } catch (_) {}
137
279
  }
138
280
  }
139
281
 
@@ -143,4 +285,8 @@ function cleanAnsi(t) {
143
285
  .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // CSI 序列(颜色/光标)
144
286
  }
145
287
 
146
- module.exports = { JumpShell, cleanAnsi, sleep, PROMPT_RE };
288
+ module.exports = {
289
+ JumpShell, cleanAnsi, sleep, PROMPT_RE,
290
+ resolveHostEntry, resolveProfile, mapConnError,
291
+ knownHostsPath, loadKnownHosts, saveKnownHosts, hostKeyFingerprint, checkHostKey,
292
+ };