@zicolasjac-ai/remote-readonly-ssh 1.0.2 → 1.0.4

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
@@ -18,6 +18,8 @@ npm install -g @zicolasjac-ai/remote-readonly-ssh
18
18
 
19
19
  要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
20
20
 
21
+ > 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
22
+
21
23
  ## 快速开始
22
24
 
23
25
  ```bash
@@ -30,9 +32,11 @@ rrs sys-info
30
32
  ## 用法
31
33
 
32
34
  ```
33
- rrs <tool> [arg] [--host <ip>] [--config <file>]
35
+ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
34
36
  ```
35
37
 
38
+ `--json` 以 JSON 输出结果(`{"ok":true,"tool":...,"host":...,"output":...}`),便于脚本/AI 调用;`-v` / `--version` 查看版本。
39
+
36
40
  | 工具 | 说明 |
37
41
  |------|------|
38
42
  | `init` | 生成配置模板 `~/.rrs/config.json` |
@@ -52,6 +56,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>]
52
56
  | 键 | 说明 |
53
57
  |-----|------|
54
58
  | `jump_server` | 堡垒机地址 / 端口 / 用户名 |
59
+ | `ssh_options` | 附加 ssh 参数(默认 `StrictHostKeyChecking=no`、兼容旧堡垒机的 ssh-rsa 算法开关,可按需调整) |
60
+ | `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
55
61
  | `private_key_path` | SSH 私钥路径(Windows 建议放 `~/.ssh/locked/` 收紧 ACL;Linux/Mac 用 `chmod 600`) |
56
62
  | `target_hosts` | 允许访问的目标主机白名单 `{ip: 堡垒机搜索关键字}` |
57
63
  | `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
@@ -61,6 +67,12 @@ rrs <tool> [arg] [--host <ip>] [--config <file>]
61
67
 
62
68
  审计日志:`~/.rrs/audit.log`
63
69
 
70
+ ## 开发
71
+
72
+ ```bash
73
+ npm test # 运行单元测试(node --test)
74
+ ```
75
+
64
76
  ## 安全模型
65
77
 
66
78
  - **双层校验**:每条命令必须以允许前缀开头,且不得含禁止模式(rm/mv/vi/systemctl/sudo/重定向到文件/shadow/ssh 密钥等),否则一律拒绝。
package/bin/rrs.js CHANGED
@@ -24,20 +24,23 @@ function printUsage() {
24
24
  ["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
25
25
  ];
26
26
  console.log("rrs — 只读 SSH 运维 CLI(白名单强制,零写能力)\n");
27
- console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>]\n");
27
+ console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]\n");
28
+ console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); -v/--version 查看版本\n");
28
29
  for (const [k, v] of cmds) {
29
30
  console.log(` ${k.padEnd(22)} ${v}`);
30
31
  }
31
32
  }
32
33
 
33
34
  function parseArgs(argv) {
34
- const opts = { tool: null, arg: null, host: null, config: null };
35
+ const opts = { tool: null, arg: null, host: null, config: null, version: false, json: false };
35
36
  const rest = [];
36
37
  for (let i = 0; i < argv.length; i++) {
37
38
  const a = argv[i];
38
39
  if (a === "--host") opts.host = argv[++i];
39
40
  else if (a === "--config") opts.config = argv[++i];
40
41
  else if (a === "-h" || a === "--help") { opts.tool = "help"; }
42
+ else if (a === "-v" || a === "--version") opts.version = true;
43
+ else if (a === "--json") opts.json = true;
41
44
  else rest.push(a);
42
45
  }
43
46
  opts.tool = opts.tool || rest[0] || "help";
@@ -102,20 +105,30 @@ function listTools(conf) {
102
105
  console.log(`\n允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
103
106
  console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
104
107
  console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
105
- console.log(`配置文件: ${process.env.READONLY_SSH_CONFIG || path.join(configDir(), "config.json")}`);
108
+ console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
106
109
  console.log(`审计日志: ${path.join(configDir(), "audit.log")}`);
107
110
  }
108
111
 
112
+ function emitResult(opts, data) {
113
+ if (opts.json) console.log(JSON.stringify(data));
114
+ else if (data.error) console.error(`[${data.kind || "错误"}] ${data.error}`);
115
+ else console.log(data.output);
116
+ }
117
+
109
118
  async function main() {
110
119
  const opts = parseArgs(process.argv.slice(2));
111
- if (opts.tool === "help" || opts.tool === "--help") { printUsage(); return; }
120
+ if (opts.version) {
121
+ console.log(require(path.join(PACKAGE_ROOT, "package.json")).version);
122
+ return;
123
+ }
124
+ if (opts.tool === "help") { printUsage(); return; }
112
125
  if (opts.tool === "init") { initConfig(); return; }
113
126
 
114
127
  let conf;
115
128
  try {
116
129
  conf = loadConf(opts.config);
117
130
  } catch (e) {
118
- console.error(`[错误] ${e.message}`);
131
+ emitResult(opts, { ok: false, error: e.message, kind: "错误" });
119
132
  process.exit(e.exitCode || 1);
120
133
  }
121
134
 
@@ -126,8 +139,8 @@ async function main() {
126
139
  host = validateHost(opts.host || Object.keys(conf.target_hosts || {})[0], conf);
127
140
  cmds = buildCmds(opts.tool, opts.arg, conf);
128
141
  } catch (e) {
129
- audit(configDir(), opts.tool, opts.host || "", [], false, e.message);
130
- console.error(`[拒绝] ${e.message}`);
142
+ audit(conf, opts.tool, opts.host || "", [], false, e.message);
143
+ emitResult(opts, { ok: false, tool: opts.tool, host: opts.host || "", error: e.message, kind: "拒绝" });
131
144
  process.exit(2);
132
145
  }
133
146
 
@@ -136,15 +149,20 @@ async function main() {
136
149
  shell.connect();
137
150
  await shell.login();
138
151
  const out = await shell.runCmds(cmds);
139
- console.log(cleanAnsi(out));
140
- audit(configDir(), opts.tool, host, cmds, true);
152
+ const cleaned = cleanAnsi(out);
153
+ emitResult(opts, { ok: true, tool: opts.tool, host, output: cleaned });
154
+ audit(conf, opts.tool, host, cmds, true, undefined, cleaned);
141
155
  } catch (e) {
142
- audit(configDir(), opts.tool, host, cmds, false, e.message);
143
- console.error(`[错误] ${e.message}`);
156
+ audit(conf, opts.tool, host, cmds, false, e.message);
157
+ emitResult(opts, { ok: false, tool: opts.tool, host, error: e.message, kind: "错误" });
144
158
  process.exit(1);
145
159
  } finally {
146
160
  shell.close();
147
161
  }
148
162
  }
149
163
 
150
- main();
164
+ if (require.main === module) {
165
+ main();
166
+ }
167
+
168
+ module.exports = { parseArgs, buildCmds, printUsage };
@@ -4,6 +4,15 @@
4
4
  "port": 2222,
5
5
  "username": "YOUR_USERNAME"
6
6
  },
7
+ "ssh_options": [
8
+ "-o", "StrictHostKeyChecking=no",
9
+ "-o", "HostKeyAlgorithms=+ssh-rsa",
10
+ "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa"
11
+ ],
12
+ "audit": {
13
+ "enabled": true,
14
+ "include_output": false
15
+ },
7
16
  "private_key_path": "C:\\Users\\YOURNAME\\.ssh\\id_rsa",
8
17
  "target_hosts": {
9
18
  "10.0.0.1": "10.0.0",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zicolasjac-ai/remote-readonly-ssh",
3
- "version": "1.0.2",
4
- "description": "Read-only SSH ops CLI: whitelist-enforced, read-only queries on servers via a bastion host. Zero write capability by design.",
3
+ "version": "1.0.4",
4
+ "description": "只读 SSH 运维 CLI:基于白名单强制校验,通过堡垒机对服务器执行只读查询,设计上零写权限。",
5
5
  "license": "MIT",
6
6
  "bin": {
7
7
  "rrs": "bin/rrs.js"
@@ -30,6 +30,6 @@
30
30
  ],
31
31
  "dependencies": {},
32
32
  "scripts": {
33
- "test": "node --test test/"
33
+ "test": "node --test"
34
34
  }
35
35
  }
package/src/config.js CHANGED
@@ -21,7 +21,7 @@ function loadConf(confPath) {
21
21
  if (!fs.existsSync(resolved)) {
22
22
  const err = new Error(
23
23
  `找不到配置文件 ${resolved}\n` +
24
- `请先运行 readonly-ssh init 生成模板,再填入您的跳板机/资产/密钥信息。`
24
+ `请先运行 rrs init 生成模板,再填入您的跳板机/资产/密钥信息。`
25
25
  );
26
26
  err.exitCode = 3;
27
27
  throw err;
@@ -70,9 +70,11 @@ function describeCmd(tool, cmd) {
70
70
  }
71
71
  }
72
72
 
73
- function audit(confDir, tool, host, cmds, ok, note) {
73
+ function audit(conf, tool, host, cmds, ok, note, output, confDir) {
74
74
  try {
75
- fs.mkdirSync(confDir, { recursive: true });
75
+ if (conf.audit && conf.audit.enabled === false) return;
76
+ const dir = confDir || configDir();
77
+ fs.mkdirSync(dir, { recursive: true });
76
78
  const d = new Date();
77
79
  const pad = (n) => String(n).padStart(2, "0");
78
80
  const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
@@ -89,8 +91,13 @@ function audit(confDir, tool, host, cmds, ok, note) {
89
91
  lines.push(` ${i + 1}. ${c}`);
90
92
  lines.push(` 用途: ${describeCmd(tool, c)}`);
91
93
  });
94
+ if (ok && output && conf.audit && conf.audit.include_output) {
95
+ const out = String(output).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim();
96
+ lines.push(` 输出摘要:`);
97
+ lines.push(out.slice(0, 4000));
98
+ }
92
99
  lines.push(bar, "");
93
- fs.appendFileSync(path.join(confDir, "audit.log"), lines.join("\n"), "utf8");
100
+ fs.appendFileSync(path.join(dir, "audit.log"), lines.join("\n"), "utf8");
94
101
  } catch {
95
102
  // 审计失败不阻断主流程
96
103
  }
package/src/jumpshell.js CHANGED
@@ -4,6 +4,25 @@ const { spawn } = require("child_process");
4
4
 
5
5
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
6
 
7
+ // 行尾 shell prompt($/#/% 可带 ~ 家目录前缀,如 "user@host:~$ ")
8
+ // 注意: 不能用 ">",会与 JumpServer 菜单行 "Opt>" 冲突
9
+ const PROMPT_RE = /(?:^|\r?\n)[^\r\n]*~?[$#%]\s*$/;
10
+
11
+ const ERR_PATTERNS = [
12
+ /permission denied/i,
13
+ /connection refused/i,
14
+ /connection timed out/i,
15
+ /host key verification failed/i,
16
+ /could not resolve hostname/i,
17
+ /no route to host/i,
18
+ ];
19
+
20
+ const DEFAULT_SSH_OPTIONS = [
21
+ "-o", "StrictHostKeyChecking=no",
22
+ "-o", "HostKeyAlgorithms=+ssh-rsa",
23
+ "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
24
+ ];
25
+
7
26
  /**
8
27
  * 通过系统 ssh 驱动 JumpServer 堡垒机菜单,登录目标资产并执行命令。
9
28
  * 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
@@ -14,27 +33,25 @@ class JumpShell {
14
33
  this.host = host;
15
34
  this.conf = conf;
16
35
  this.proc = null;
17
- this.buf = "";
18
- this.stopped = false;
36
+ this.out = "";
37
+ this.err = "";
38
+ this.terminated = false;
19
39
  }
20
40
 
21
41
  connect() {
22
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;
23
46
  const args = [
24
47
  "-i", this.conf.private_key_path,
25
48
  "-p", String(js.port),
26
- "-o", "StrictHostKeyChecking=no",
27
- "-o", "HostKeyAlgorithms=+ssh-rsa",
28
- "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
49
+ ...opts,
29
50
  "-tt", `${js.username}@${js.host}`,
30
51
  ];
31
52
  this.proc = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
32
- this.proc.stdout.on("data", (d) => {
33
- this.buf += d.toString("utf8");
34
- });
35
- this.proc.stderr.on("data", (d) => {
36
- this.buf += d.toString("utf8");
37
- });
53
+ this.proc.stdout.on("data", (d) => { this.out += d.toString("utf8"); });
54
+ this.proc.stderr.on("data", (d) => { this.err += d.toString("utf8"); });
38
55
  }
39
56
 
40
57
  send(s) {
@@ -43,51 +60,87 @@ class JumpShell {
43
60
  }
44
61
  }
45
62
 
46
- async waitFor(marker, secs) {
47
- const deadline = Date.now() + secs * 1000;
63
+ checkErr() {
64
+ for (const p of ERR_PATTERNS) {
65
+ if (p.test(this.err)) {
66
+ const last = this.err.trim().split("\n").pop();
67
+ throw new Error(`SSH 错误: ${last}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ isPrompt() {
73
+ return PROMPT_RE.test(this.out);
74
+ }
75
+
76
+ async waitFor(marker, deadline) {
77
+ while (Date.now() < deadline) {
78
+ if (this.terminated) throw new Error("SSH 连接已断开");
79
+ this.checkErr();
80
+ if (this.out.includes(marker)) return true;
81
+ await sleep(150);
82
+ }
83
+ return false;
84
+ }
85
+
86
+ async waitPrompt(deadline) {
87
+ while (Date.now() < deadline) {
88
+ if (this.terminated) throw new Error("SSH 连接已断开");
89
+ this.checkErr();
90
+ if (this.isPrompt()) return true;
91
+ await sleep(150);
92
+ }
93
+ return false;
94
+ }
95
+
96
+ async waitPromptAfter(snapshot, deadline) {
48
97
  while (Date.now() < deadline) {
49
- if (this.buf.includes(marker)) return true;
98
+ if (this.terminated) throw new Error("SSH 连接已断开");
99
+ this.checkErr();
100
+ if (this.out.length > snapshot && this.isPrompt()) return true;
50
101
  await sleep(150);
51
102
  }
52
103
  return false;
53
104
  }
54
105
 
55
- async login() {
56
- if (!(await this.waitFor("Opt>", 25))) throw new Error("跳板机菜单未出现");
106
+ async login(timeoutSecs = 30) {
107
+ const deadline = Date.now() + timeoutSecs * 1000;
108
+ if (!(await this.waitFor("Opt>", deadline))) throw new Error("跳板机菜单未出现");
57
109
  await sleep(1000);
58
110
  this.send("");
59
111
  await sleep(2000);
60
112
  this.send(this.searchKeyword);
61
- if (!(await this.waitFor(this.host, 15))) throw new Error(`未找到目标资产 ${this.host}`);
62
- if (!(await this.waitFor("~", 15))) throw new Error("未能进入 shell");
113
+ if (!(await this.waitFor(this.host, deadline))) throw new Error(`未找到目标资产 ${this.host}`);
114
+ if (!(await this.waitPrompt(deadline))) throw new Error("未能进入 shell");
63
115
  await sleep(500);
64
116
  }
65
117
 
66
- async runCmds(cmds, waitSecs = 4) {
67
- const start = this.buf.length;
118
+ async runCmds(cmds, timeoutSecs = 30) {
119
+ const deadline = Date.now() + timeoutSecs * 1000;
120
+ const start = this.out.length;
68
121
  for (const c of cmds) {
122
+ if (Date.now() >= deadline) throw new Error("命令执行总超时");
123
+ const snapshot = this.out.length;
69
124
  this.send(c);
70
- await sleep(waitSecs * 1000);
125
+ if (!(await this.waitPromptAfter(snapshot, deadline))) throw new Error(`等待命令执行完成超时: ${c}`);
71
126
  }
72
- return this.buf.slice(start);
127
+ return this.out.slice(start);
73
128
  }
74
129
 
75
130
  close() {
76
- this.stopped = true;
77
- try {
78
- this.send("exit");
79
- setTimeout(() => {
80
- try { this.send("exit"); } catch {}
81
- setTimeout(() => {
82
- try { this.proc.kill(); } catch {}
83
- }, 1000);
84
- }, 1000);
85
- } catch {}
131
+ 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);
86
137
  }
87
138
  }
88
139
 
89
140
  function cleanAnsi(t) {
90
- return t.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
141
+ return t
142
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "") // OSC 序列(终端标题等)
143
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // CSI 序列(颜色/光标)
91
144
  }
92
145
 
93
- module.exports = { JumpShell, cleanAnsi, sleep };
146
+ module.exports = { JumpShell, cleanAnsi, sleep, PROMPT_RE };
package/src/whitelist.js CHANGED
@@ -4,7 +4,7 @@ const path = require("path");
4
4
 
5
5
  const FORBIDDEN_PATTERNS = [
6
6
  />\s*[^|&]/, // output redirection to file (2>&1 stream redirect is allowed)
7
- /\b(rm|mv|cp|tar|install|yum|dnf|apt-get|vi|vim|nano|mkdir|touch|chmod|chown|useradd|userdel|passwd|sudo|su|reboot|shutdown|systemctl|kill|pkill|wget|curl|nc)\b/,
7
+ /\b(rm|mv|cp|tar|install|yum|dnf|apt-get|vi|vim|nano|mkdir|touch|chmod|chown|useradd|userdel|passwd|sudo|su|reboot|shutdown|systemctl|kill|pkill|wget|curl|nc|tee|xargs|dd|bash|python|perl|ruby|awk|scp|sftp|rsync|git)\b/,
8
8
  /nginx\s+-s\b/, // nginx signal controls (stop/quit/reload/reopen) are mutating
9
9
  /sed\s+-i\b/, // sed in-place edit is mutating (defense in depth)
10
10
  /-exec\b|-execdir\b|-ok\b|-okdir\b/, // find exec actions run arbitrary commands
@@ -46,10 +46,12 @@ function validateCmd(cmd, conf) {
46
46
  return c;
47
47
  }
48
48
 
49
+ const PATH_RE = /^[A-Za-z0-9._\-/:*]+$/;
50
+
49
51
  function validatePath(pathArg, conf) {
50
52
  const raw = String(pathArg).trim();
51
53
  if (!raw.startsWith("/")) throw new Error(`拒绝: 仅允许绝对路径 [${raw}]`);
52
- if (raw.includes("\0")) throw new Error(`拒绝: 非法字符 [${raw}]`);
54
+ if (!PATH_RE.test(raw)) throw new Error(`拒绝: 路径含非法字符 [${raw}]`);
53
55
  // normalize resolves any . or .. segments BEFORE whitelist checks
54
56
  const p = path.posix.normalize(raw);
55
57
  const prefixes = conf.allowed_path_prefixes || [];
@@ -71,7 +73,8 @@ function validateHost(host, conf) {
71
73
  }
72
74
 
73
75
  function validateArg(arg, what) {
74
- if (!arg || !ARG_RE.test(arg)) {
76
+ if (!arg) throw new Error(`拒绝: 缺少 ${what} 参数`);
77
+ if (!ARG_RE.test(arg)) {
75
78
  throw new Error(`拒绝: ${what} 含非法字符 [${arg}]`);
76
79
  }
77
80
  return arg;