@zicolasjac-ai/remote-readonly-ssh 1.4.0 → 1.4.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **CLI and Agent Skill for safe read-only SSH inspection via JumpServer.**
4
4
 
5
- 让 AI 助手(opencode / Claude Code / Cursor / Windsurf / Codex 等)通过堡垒机白名单强制地在服务器上做只读查询——技术上零写能力,每次调用(含被拒绝的)写入本地审计日志。
5
+ 让 AI 助手(opencode / Claude Code / Cursor / Windsurf / Codex 等)通过堡垒机白名单强制地在服务器上做只读查询——技术上零写能力,每次调用(含被拒绝的)写入本地审计日志;另支持与 `direct: true` 直连资产通过 SFTP 做单文件传输(下载开箱可用、带敏感路径防呆;**上传默认禁用**,须配置显式开启 + 独立写路径白名单)。
6
6
 
7
7
  ## 为什么需要它
8
8
 
@@ -68,6 +68,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
68
68
  | `dir <path>` | 列目录(同路径白名单) |
69
69
  | `bin-version <bin>` | 查二进制版本/编译参数(二进制白名单) |
70
70
  | `exec-only <cmd>` | 通用只读命令(前缀白名单 + 禁止模式) |
71
+ | `download <远程> <本地>` | 下载单文件(SFTP,仅 direct 资产;shadow/.ssh 类路径防呆) |
72
+ | `upload <本地> <远程>` | 上传单文件(写操作,**默认禁用**:须 `file_transfer.upload.enabled=true` + 独立写路径白名单) |
71
73
 
72
74
  ## 配置
73
75
 
@@ -83,6 +85,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
83
85
  | `allowed_path_prefixes` | 允许读取的路径前缀 |
84
86
  | `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
85
87
  | `container_exec` | 可选:`docker exec` 容器只读策略(允许子命令、路径前缀、递归路径前缀、超时、输出上限) |
88
+ | `file_transfer` | 可选:文件传输策略 `{upload: {enabled, allowed_path_prefixes}, download: {blocked_paths}, verify}`,见下节 |
86
89
 
87
90
  > **凭据安全提示**:密码为明文存放于本机 `config.json`(与私钥文件同级信任,仅本机可读)。Windows 建议收紧 ACL,Linux/macOS 建议 `chmod 600 ~/.rrs/config.json`。
88
91
  >
@@ -90,6 +93,52 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
90
93
 
91
94
  审计日志:`~/.rrs/audit.log`
92
95
 
96
+ ## 文件传输(SFTP,单文件)
97
+
98
+ `download` / `upload` 通过 SSH **SFTP 子系统**传文件,与巡检命令共用同一套连接层(TOFU 主机密钥校验、审计留痕一致)。
99
+
100
+ **硬约束**:
101
+
102
+ - **仅支持 `direct: true` 直连认证组**。走堡垒机菜单的资产没有指向目标资产的 SFTP 通路,调用会被直接拒绝(这是防"误写到堡垒机文件系统"的保护,不是缺陷);如需传输,请为该主机增加一个 `direct: true` 的专属认证组。
103
+ - **大小不设上限**(1GB 级为常规场景),多线路网络下观察 `--` 进度提示即可判断耗时。
104
+ - 目标(两端)**已存在即拒绝**:`--overwrite` 从头重传;`--resume` 断点续传未完成半成品 `<目标>.rrs-part`(互斥,续传前校验两端字节数一致)。产物先写半成品、校验后原子改名,**见到正式文件即视为完整**。
105
+ - 单文件传输;不支持目录递归、不支持容器内文件。
106
+
107
+ **下载**(默认可用):
108
+
109
+ ```bash
110
+ rrs download /var/log/app.log "E:\logs\app.log" --host 10.0.0.2 --json
111
+ rrs download /var/log/app.log "E:\logs\app.log" --resume --host 10.0.0.2 # 续传
112
+ ```
113
+
114
+ 不做读白名单限制,但敏感路径防呆生效:内置 `/etc/shadow` 族、任意 `/.ssh/` 目录/密钥一律拒绝,且你的 `blocked_paths` 全局黑名单与 `file_transfer.download.blocked_paths` 追加同样生效(读白名单放开 ≠ 敏感黑名单放开)。
115
+
116
+ **上传**(opt-in,默认拒绝):
117
+
118
+ 在 `~/.rrs/config.json` 显式开启(写路径与读路径分开管,不上读白名单的当):
119
+
120
+ ```json
121
+ "file_transfer": {
122
+ "upload": {
123
+ "enabled": true,
124
+ "allowed_path_prefixes": ["/opt/upload/", "/data/deploy/"]
125
+ },
126
+ "download": { "blocked_paths": [] },
127
+ "verify": "size"
128
+ }
129
+ ```
130
+
131
+ ```bash
132
+ rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
133
+ ```
134
+
135
+ - 未配置 `allowed_path_prefixes` 时**上传一律拒绝**(避免"开了开关即全盘可写")。
136
+ - 远程路径拒绝控制字符与 shell 元字符;`--overwrite` / `--resume` 语义同上。
137
+
138
+ **完整性校验**:默认核两端**字节大小**(零开销)。需要防"传输期间文件被改"时把 `verify` 设为 `"sha256"`——远端会跑 `sha256sum`,因此须把 `"sha256sum "` 加入 `allowed_command_prefixes`(黑白名单语义不受影响,缺白名单会明确报错而非静默绕过)。
139
+
140
+ `--json` 输出含 `bytes` / `elapsed_ms` / `resumed` / `verify` 等元数据;终端下进度打在 stderr,不污染 JSON。
141
+
93
142
  ## Agent Skills
94
143
 
95
144
  本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。安装后用 `rrs setup skills` 一键分发到各 agent 的 skills 目录:
package/bin/rrs.js CHANGED
@@ -10,6 +10,8 @@ const {
10
10
  } = require("../src/whitelist");
11
11
  const setupSkill = require("../src/setup");
12
12
  const initWizard = require("../src/init");
13
+ const transfer = require("../src/transfer");
14
+ const { resolveProfile } = require("../src/jumpshell");
13
15
 
14
16
  const PACKAGE_ROOT = path.join(__dirname, "..");
15
17
 
@@ -24,13 +26,17 @@ function printUsage() {
24
26
  ["dir <path>", "列目录(路径白名单)"],
25
27
  ["bin-version <bin>", "查二进制版本(二进制白名单)"],
26
28
  ["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
29
+ ["download <远程> <本地>", "下载单文件(SFTP,仅 direct 主机;内置敏感路径防呆)"],
30
+ ["upload <本地> <远程>", "上传单文件(SFTP,默认禁用需配置开启 + 写路径白名单)"],
27
31
  ["setup skills [--agent <n>]", "把 SKILL.md 装到 agents(实体) + opencode/claude/qoder(软链接)"],
28
32
  ["setup uninstall skills [--agent <n>]", "移除上述目录的 skill"],
29
33
  ];
30
34
  console.log("rrs — CLI and Agent Skill for safe read-only SSH inspection\n");
31
35
  console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]");
36
+ console.log(" rrs download <远程绝对路径> <本地绝对路径> [--overwrite|--resume] [--host <ip>]");
37
+ console.log(" rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--host <ip>]");
32
38
  console.log(" rrs setup skills [--agent opencode|claude|cursor|windsurf|codex|openhands|agents|all] [--dry-run]\n");
33
- console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); -v/--version 查看版本\n");
39
+ console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); --overwrite 重传; --resume 断点续传; -v/--version 查看版本\n");
34
40
  for (const [k, v] of cmds) {
35
41
  console.log(` ${k.padEnd(40)} ${v}`);
36
42
  }
@@ -48,6 +54,8 @@ function parseArgs(argv) {
48
54
  config: null,
49
55
  version: false,
50
56
  json: false,
57
+ overwrite: false,
58
+ resume: false,
51
59
  };
52
60
  const rest = [];
53
61
  for (let i = 0; i < argv.length; i++) {
@@ -56,6 +64,8 @@ function parseArgs(argv) {
56
64
  else if (a === "--config") opts.config = argv[++i];
57
65
  else if (a === "--agent") opts.agent = argv[++i];
58
66
  else if (a === "--dry-run") opts.dryRun = true;
67
+ else if (a === "--overwrite") opts.overwrite = true;
68
+ else if (a === "--resume") opts.resume = true;
59
69
  else if (a === "-h" || a === "--help") { opts.mode = "tool"; opts.tool = "help"; }
60
70
  else if (a === "-v" || a === "--version") opts.version = true;
61
71
  else if (a === "--json") opts.json = true;
@@ -74,6 +84,7 @@ function parseArgs(argv) {
74
84
  }
75
85
  opts.tool = opts.tool || rest[0] || "help";
76
86
  opts.arg = rest[1] || null;
87
+ opts.arg2 = rest[2] || null;
77
88
  return opts;
78
89
  }
79
90
 
@@ -188,6 +199,10 @@ function listTools(conf) {
188
199
  console.log(`允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
189
200
  console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
190
201
  console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
202
+ const ft = conf.file_transfer || {};
203
+ const upEnabled = ft.upload && ft.upload.enabled === true;
204
+ const upPrefixes = (ft.upload && ft.upload.allowed_path_prefixes || []).join(", ") || "(未配置=全禁)";
205
+ console.log(`文件传输: 下载=可用(内置敏感路径防呆) | 上传=${upEnabled ? `启用(写路径前缀: ${upPrefixes})` : "禁用(file_transfer.upload.enabled=true 开启)"}`);
191
206
  console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
192
207
  console.log(`审计日志: ${path.join(configDir(), "audit.log")}`);
193
208
  }
@@ -198,6 +213,97 @@ function emitResult(opts, data) {
198
213
  else console.log(data.output);
199
214
  }
200
215
 
216
+ // ==== 文件传输(upload / download)====
217
+
218
+ function fmtBytes(n) {
219
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
220
+ let v = Number(n) || 0;
221
+ let i = 0;
222
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
223
+ return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
224
+ }
225
+
226
+ function fmtDur(ms) {
227
+ const s = Math.round(ms / 1000);
228
+ if (s < 60) return `${s}s`;
229
+ const m = Math.floor(s / 60);
230
+ return `${m}m${String(s % 60).padStart(2, "0")}s`;
231
+ }
232
+
233
+ /** 进度只打到 stderr(不污染 --json 的 stdout),且仅终端场景显示 */
234
+ function makeProgress(opts) {
235
+ if (opts.json || !process.stderr.isTTY) return null;
236
+ const startedAt = Date.now();
237
+ return (sent, total) => {
238
+ const pct = total > 0 ? ((sent / total) * 100).toFixed(1) : "-";
239
+ const speed = sent / 1048576 / Math.max(0.25, (Date.now() - startedAt) / 1000);
240
+ process.stderr.write(` 已传 ${fmtBytes(sent)} / ${fmtBytes(total)}(${pct}%,~${speed.toFixed(1)} MiB/s) \r`);
241
+ };
242
+ }
243
+
244
+ async function runTransfer(opts, conf) {
245
+ const tool = opts.tool;
246
+ const isUpload = tool === "upload";
247
+ // 语义:upload <本地> <远程>;download <远程> <本地>
248
+ const args = isUpload
249
+ ? { local: opts.arg, remote: opts.arg2, overwrite: opts.overwrite, resume: opts.resume }
250
+ : { remote: opts.arg, local: opts.arg2, overwrite: opts.overwrite, resume: opts.resume };
251
+
252
+ // ---- 静态校验(拒绝类:退出码 2)----
253
+ let host = null;
254
+ let cmds = [];
255
+ let plan;
256
+ try {
257
+ host = validateHost(opts.host || Object.keys(conf.target_hosts || {})[0], conf);
258
+ cmds = [isUpload
259
+ ? `upload ${args.local || "(无)"} -> ${args.remote || "(无)"}`
260
+ : `download ${args.remote || "(无)"} -> ${args.local || "(无)"}`];
261
+ plan = transfer.prepareTransfer(conf, host, tool, args);
262
+ const entry = (conf.target_hosts || {})[host] || {};
263
+ const profile = resolveProfile(conf, host);
264
+ if (profile.direct !== true) {
265
+ throw new Error(`拒绝: 文件传输仅支持 direct: true 认证组;主机 [${host}] 当前认证组 [${entry.profile || "(未知)"}] 未启用直连。请在 auth_profiles 为该主机增加 direct: true 的专属认证组`);
266
+ }
267
+ } catch (e) {
268
+ audit(conf, tool, host || "", cmds, false, e.message);
269
+ emitResult(opts, { ok: false, tool, host: host || "", error: e.message, kind: "拒绝" });
270
+ process.exit(2);
271
+ }
272
+
273
+ // ---- 连接与执行(错误类:退出码 1)----
274
+ const shell = new JumpShell(conf, host);
275
+ try {
276
+ await shell.connect(); // direct 连接即到目标机,无需菜单 login
277
+ const onProgress = makeProgress(opts);
278
+ const result = isUpload
279
+ ? await transfer.uploadFile(shell, conf, plan, onProgress)
280
+ : await transfer.downloadFile(shell, conf, plan, onProgress);
281
+ shell.close();
282
+ try { if (onProgress) process.stderr.write("\n"); } catch (_) {}
283
+
284
+ const speedBps = result.bytes / Math.max(0.001, result.elapsedMs / 1000);
285
+ const resumeTag = result.resumed ? "(断点续传)" : "";
286
+ const verifyTag = result.verify === "sha256" ? ",sha256 校验通过" : "";
287
+ const text = `${isUpload ? "上传" : "下载"}完成: ${plan.local} ↔ ${plan.remote} | ${fmtBytes(result.bytes)} 耗时 ${fmtDur(result.elapsedMs)} 均 ${(speedBps / 1048576).toFixed(1)} MiB/s${resumeTag}${verifyTag}`;
288
+ audit(conf, tool, host, cmds, true,
289
+ `bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite}`);
290
+ emitResult(opts, {
291
+ ok: true, tool, host,
292
+ local: plan.local, remote: plan.remote,
293
+ bytes: result.bytes, resumed: result.resumed,
294
+ elapsed_ms: result.elapsedMs, verify: result.verify,
295
+ overwrite: plan.flags.overwrite,
296
+ output: text,
297
+ });
298
+ } catch (e) {
299
+ shell.close();
300
+ try { if (!opts.json && process.stderr.isTTY) process.stderr.write("\n"); } catch (_) {}
301
+ audit(conf, tool, host, cmds, false, e.message);
302
+ emitResult(opts, { ok: false, tool, host, error: e.message, kind: "错误" });
303
+ process.exit(1);
304
+ }
305
+ }
306
+
201
307
  async function main() {
202
308
  const opts = parseArgs(process.argv.slice(2));
203
309
  if (opts.version) {
@@ -224,6 +330,7 @@ async function main() {
224
330
  }
225
331
 
226
332
  if (opts.tool === "list-tools") { listTools(conf); return; }
333
+ if (opts.tool === "upload" || opts.tool === "download") { await runTransfer(opts, conf); return; }
227
334
 
228
335
  let host, cmds;
229
336
  try {
@@ -90,6 +90,16 @@
90
90
  "/etc/",
91
91
  "/var/log/"
92
92
  ],
93
+ "file_transfer": {
94
+ "upload": {
95
+ "enabled": false,
96
+ "allowed_path_prefixes": ["/opt/upload/"]
97
+ },
98
+ "download": {
99
+ "blocked_paths": []
100
+ },
101
+ "verify": "size"
102
+ },
93
103
  "blocked_paths": [
94
104
  "/etc/shadow",
95
105
  "/etc/passwd",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zicolasjac-ai/remote-readonly-ssh",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;认证组支持 SSH 私钥/密码两种模式与按 IP 分组;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  name: remote-readonly-ssh
3
- description: 通过 JumpServer 堡垒机对服务器做白名单强制的只读运维查询(sys-info / proc / ports / dir / read-file / bin-version / exec-only)。技术强制只读、命令前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验,每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要做"查看服务器现状"这类操作时调用此 skill;它不会写入任何远程状态。
3
+ description: 通过 JumpServer 堡垒机对服务器做白名单强制的运维查询与单文件传输(sys-info / proc / ports / dir / read-file / bin-version / exec-only / download / upload)。命令默认只读、前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验;下载自带敏感路径防呆,上传默认禁用需配置显式开启且仅支持 direct 直连资产。每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要查看服务器现状或与 direct 服务器互传单文件时调用此 skill。
4
4
  license: MIT
5
5
  compatibility: opencode, claude-code, cursor, windsurf, openhands, qoder, codex
6
6
  metadata:
7
7
  audience: ai-agents
8
8
  category: ops
9
- safe: read-only
9
+ safe: readonly-with-audited-optional-upload
10
10
  ---
11
11
 
12
- # rrs — Read-Only SSH Inspection Skill
12
+ # rrs — Read-Only SSH Inspection & File Transfer Skill
13
13
 
14
- `rrs`(remote-read-only-ssh)是一个面向 AI Agent 的只读 SSH 运维工具。它通过 JumpServer 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询。每次调用(含被拒绝的)都写入本地审计日志。
14
+ `rrs`(remote-read-only-ssh)是一个面向 AI Agent 的只读 SSH 运维工具。它通过 JumpServer 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询,并支持与 `direct: true` 直连资产做单文件上传/下载(SFTP)。每次调用(含被拒绝的)都写入本地审计日志。
15
+
16
+ 安全定位:**命令查询技术上零写能力**;文件下载原则上不写远端;**文件上传是唯一写远程状态的能力**——默认禁用,须在本机配置显式开启 + 写路径白名单。
15
17
 
16
18
  ## When to use me
17
19
 
@@ -24,8 +26,10 @@ metadata:
24
26
  - 列目录结构(`dir /var/log`)
25
27
  - 查二进制版本(`bin-version nginx`)
26
28
  - 跑一段自定义只读命令(`exec-only "df -h | head"`)
29
+ - 从 `direct` 直连服务器**下载**文件到本地(`download /var/log/app.log 本地路径`)
30
+ - 向 `direct` 直连服务器**上传**文件(仅当用户已配置启用上传后可用;运行前先征得用户同意,因为这是写操作)
27
31
 
28
- **不要**用它做任何写操作——rm / mv / cp / vi / sed -i / systemctl / 重定向到文件 等一律被拒绝。
32
+ **不要**用它做任何写操作——rm / mv / cp / vi / sed -i / systemctl / 重定向到文件 等一律被拒绝;**不要**试图用 `upload` 绕过这一约束,上传路径有独立白名单。
29
33
 
30
34
  ## Setup(用户一次性)
31
35
 
@@ -41,6 +45,8 @@ rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
41
45
 
42
46
  ```bash
43
47
  rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
48
+ rrs download <远程绝对路径> <本地绝对路径> [--overwrite|--resume] [--host <ip>]
49
+ rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--host <ip>]
44
50
  ```
45
51
 
46
52
  **始终加 `--json`**,便于解析。
@@ -56,6 +62,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
56
62
  | `dir <path>` | 列目录 | `rrs dir /var/log --json` |
57
63
  | `bin-version <bin>` | 查二进制版本/编译参数 | `rrs bin-version nginx --json` |
58
64
  | `exec-only <cmd>` | 通用只读命令(白名单校验) | `rrs exec-only "df -h" --json` |
65
+ | `download <远程> <本地>` | 下载单文件(SFTP,仅 direct 资产) | `rrs download /var/log/app.log E:\logs\app.log --json` |
66
+ | `upload <本地> <远程>` | 上传单文件(opt-in,写路径白名单) | `rrs upload E:\deploy\a.conf /opt/upload/a.conf --json` |
59
67
  | `list-tools` | 查看白名单 / 配置 | `rrs list-tools --json` |
60
68
  | `init` | 生成配置模板 | `rrs init` |
61
69
 
@@ -63,6 +71,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
63
71
 
64
72
  - `--host <ip>`:指定目标主机(必须存在于 `target_hosts` 白名单)
65
73
  - `--config <file>`:使用自定义配置文件
74
+ - `--overwrite`:传输目标已存在时从头重传(默认拒绝覆盖)
75
+ - `--resume`:断点续传半成品 `.rrs-part` 文件(与 `--overwrite` 互斥)
66
76
  - `--json`:以 JSON 输出
67
77
  - `-v` / `--version`:查看版本
68
78
  - `-h` / `--help`:查看用法
@@ -75,6 +85,12 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
75
85
  {"ok":true,"tool":"sys-info","host":"10.0.0.1","output":"..."}
76
86
  ```
77
87
 
88
+ 传输成功(JSON 含传输元数据):
89
+
90
+ ```json
91
+ {"ok":true,"tool":"download","host":"10.0.0.2","local":"E:\\logs\\app.log","remote":"/var/log/app.log","bytes":1234,"resumed":true,"elapsed_ms":5678,"verify":"size","overwrite":false,"output":"下载完成: ..."}
92
+ ```
93
+
78
94
  拒绝(参数非法 / 敏感路径 / 命令不在白名单):
79
95
 
80
96
  ```json
@@ -97,6 +113,14 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
97
113
  4. **不要**试图把 `;` `&&` `|` `>` `$()` 拼到参数里——所有路径和参数都做严格字符集校验
98
114
  5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
99
115
  6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
116
+ 7. **传输约束(upload/download)**:
117
+ - 仅支持 `direct: true` 直连认证组;菜单模式资产一律拒绝(不要为传输把读文件当替代方案混传)
118
+ - `upload` 是**写操作**:默认禁用;只有用户明确要求传文件且配置已启用(`file_transfer.upload.enabled: true` + 写路径白名单)时才可用;启用与否、白名单变更属配置修改,须先征得用户同意(同规则 6)
119
+ - `download` 无读白名单,但敏感路径防呆生效:内置(`/etc/shadow` 族、任意 `/.ssh/` 目录)+ `blocked_paths` 全局黑名单 + `file_transfer.download.blocked_paths` 追加;被拒即是禁止信号,**不要**换路径表达方式重试(可把需求转述给用户人工决策)
120
+ - `upload` 在写白名单之上同样受防呆约束(`.ssh` / shadow / `blocked_paths` 族不可覆盖)
121
+ - 仅单文件;**不支持**目录递归与容器内传文件;目标已存在默认拒绝,`--overwrite` / `--resume` 二选一且互斥
122
+ - 断点续传依赖 `.rrs-part` 半成品文件;续传前工具会校验两端字节数一致
123
+ - `file_transfer.verify: "sha256"` 时远端会跑 `sha256sum`(须加入 `allowed_command_prefixes`);未开启时只核字节大小,AI 不应宣称"内容校验通过"
100
124
 
101
125
  被拒绝的尝试一样会进审计日志。
102
126
 
@@ -115,4 +139,6 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
115
139
  ...
116
140
  ```
117
141
 
118
- 如需让审计同时记录命令输出摘要,在 `~/.rrs/config.json` 设置 `"audit": { "include_output": true }`。
142
+ 如需让审计同时记录命令输出摘要,在 `~/.rrs/config.json` 设置 `"audit": { "include_output": true }`。
143
+
144
+ transmit 类调用(`upload`/`download`)同样按上述格式落审计:巡检项为"文件上传(写操作·opt-in)"/"文件下载",备注含字节数、耗时、是否续传与校验方式(不含文件内容)。
package/src/config.js CHANGED
@@ -123,6 +123,40 @@ function validateConfig(conf) {
123
123
  }
124
124
  }
125
125
 
126
+ const ft = conf.file_transfer;
127
+ if (ft !== undefined) {
128
+ if (!ft || typeof ft !== "object" || Array.isArray(ft)) {
129
+ errors.push("file_transfer 必须是对象 {upload: {enabled, allowed_path_prefixes}, download: {blocked_paths}, verify}");
130
+ } else {
131
+ if (ft.upload !== undefined) {
132
+ if (!ft.upload || typeof ft.upload !== "object" || Array.isArray(ft.upload)) {
133
+ errors.push("file_transfer.upload 必须是对象 {enabled, allowed_path_prefixes}");
134
+ } else {
135
+ if (ft.upload.enabled !== undefined && typeof ft.upload.enabled !== "boolean") {
136
+ errors.push(`file_transfer.upload.enabled 必须是布尔值(true=显式启用上传),当前: ${ft.upload.enabled}`);
137
+ }
138
+ if (ft.upload.allowed_path_prefixes !== undefined) {
139
+ if (!Array.isArray(ft.upload.allowed_path_prefixes) || !ft.upload.allowed_path_prefixes.every((x) => typeof x === "string" && x)) {
140
+ errors.push("file_transfer.upload.allowed_path_prefixes 必须是非空字符串数组(上传写入路径白名单)");
141
+ }
142
+ }
143
+ }
144
+ }
145
+ if (ft.download !== undefined) {
146
+ if (!ft.download || typeof ft.download !== "object" || Array.isArray(ft.download)) {
147
+ errors.push("file_transfer.download 必须是对象 {blocked_paths}");
148
+ } else if (ft.download.blocked_paths !== undefined) {
149
+ if (!Array.isArray(ft.download.blocked_paths) || !ft.download.blocked_paths.every((x) => typeof x === "string" && x)) {
150
+ errors.push("file_transfer.download.blocked_paths 必须是非空字符串数组");
151
+ }
152
+ }
153
+ }
154
+ if (ft.verify !== undefined && ft.verify !== "size" && ft.verify !== "sha256") {
155
+ errors.push(`file_transfer.verify 必须是 "size" 或 "sha256",当前: ${ft.verify}`);
156
+ }
157
+ }
158
+ }
159
+
126
160
  if (errors.length) throw cfgError(errors.join(";"));
127
161
  return true;
128
162
  }
@@ -135,6 +169,8 @@ const TOOL_PURPOSE = {
135
169
  "dir": "目录结构核对",
136
170
  "bin-version": "软件版本核对",
137
171
  "exec-only": "只读命令核查",
172
+ "upload": "文件上传(写操作·opt-in)",
173
+ "download": "文件下载(内置敏感路径防呆)",
138
174
  };
139
175
 
140
176
  function describeCmd(tool, cmd) {
@@ -159,6 +195,12 @@ function describeCmd(tool, cmd) {
159
195
  const m = cmd.match(/^([^\s]+)/);
160
196
  return m ? `核对 ${m[1]} 版本信息` : "核对版本信息";
161
197
  }
198
+ case "upload":
199
+ return "上传本地文件到服务器(写操作,须配置显式启用)";
200
+ case "download":
201
+ return "下载服务器文件到本机(黑名单防呆:shadow / .ssh)";
202
+ case "verify-sha256":
203
+ return "传输完整性校验(sha256sum,只读)";
162
204
  default:
163
205
  return TOOL_PURPOSE[tool] || "只读核查";
164
206
  }
package/src/jumpshell.js CHANGED
@@ -240,6 +240,51 @@ class JumpShell {
240
240
  }
241
241
  }
242
242
 
243
+ /**
244
+ * 打开 SFTP 子系统通道(文件传输专用)。仅支持 direct: true 直连认证组:
245
+ * 堡垒机菜单模式下没有指向目标资产的 SFTP 通路(菜单会话只是 PTY),
246
+ * 若堡垒机自身恰好提供 SFTP 子系统,路径语义也指向堡垒机而非目标资产,属错位写入,必须拒绝。
247
+ */
248
+ async openSftp() {
249
+ if (this.direct !== true) {
250
+ throw new Error(
251
+ `文件传输仅支持 direct: true 认证组(当前主机 ${this.host} 未启用直连)。` +
252
+ `请在 auth_profiles 中为该主机增加一个 direct: true 的专属认证组,并在 target_hosts 引用它`
253
+ );
254
+ }
255
+ if (!this.conn) throw new Error("SSH 未连接");
256
+ return new Promise((resolve, reject) => {
257
+ this.conn.sftp((err, sftp) => {
258
+ if (err) {
259
+ reject(new Error(`SFTP 子系统打开失败: ${err.message}(目标机需运行 OpenSSH sftp-server;口令账号需有读写权限)`));
260
+ } else {
261
+ resolve(sftp);
262
+ }
263
+ });
264
+ });
265
+ }
266
+
267
+ /**
268
+ * 直连模式下在目标机执行单条已校验命令并收集 stdout/stderr/exit code。
269
+ * 用于 file_transfer.verify: "sha256" 时跑远端 sha256sum(命令须经 validateCmd 白名单校验后传入)。
270
+ */
271
+ execOnce(cmd) {
272
+ if (!this.conn) throw new Error("SSH 未连接");
273
+ return new Promise((resolve, reject) => {
274
+ this.conn.exec(cmd, { pty: false }, (err, stream) => {
275
+ if (err) return reject(mapConnError(err));
276
+ let out = "";
277
+ let errText = "";
278
+ stream.on("data", (d) => { out += d.toString("utf8"); });
279
+ if (stream.stderr) stream.stderr.on("data", (d) => { errText += d.toString("utf8"); });
280
+ stream.on("close", (code) => {
281
+ resolve({ code: code == null ? 0 : code, stdout: out, stderr: errText });
282
+ });
283
+ stream.on("error", reject);
284
+ });
285
+ });
286
+ }
287
+
243
288
  checkErr() {
244
289
  for (const p of ERR_PATTERNS) {
245
290
  if (p.test(this.err)) {
@@ -0,0 +1,422 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * 文件传输(upload / download)核心逻辑,基于 SFTP 子系统(仅 direct: true 认证组)。
5
+ *
6
+ * 安全模型(用户决策记录):
7
+ * - 上传是写操作:默认全禁,须配置 file_transfer.upload.enabled=true + allowed_path_prefixes
8
+ * - 下载不做读白名单:仅内置敏感路径防呆(shadow / .ssh 族)+ 可配置追加黑名单
9
+ * - 大小不设上限;存在即拒绝,--overwrite 重传 / --resume 断点续传(两 flag 互斥)
10
+ * - 产物先写 .rrs-part 半成品,校验通过后原子改名,正式目标出现即视为完整
11
+ * - 校验默认只核字节大小;verify:"sha256" 时远端跑 sha256sum(须加入 allowed_command_prefixes)
12
+ * - 单文件;不支持目录递归与容器内传输
13
+ */
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const crypto = require("crypto");
18
+ const { shellQuote } = require("./jumpshell");
19
+ const {
20
+ transferConf, validateRemotePath, partPath, resolveTransferFlags, computeResumeOffset, validateCmd,
21
+ } = require("./whitelist");
22
+
23
+ // 30s 无任何字节增量视为传输停滞(避免连接静默挂死)
24
+ const STALL_TIMEOUT_MS = 30000;
25
+ // 进度回调节流间隔
26
+ const PROGRESS_INTERVAL_MS = 2000;
27
+
28
+ function tErr(msg) {
29
+ return new Error(msg);
30
+ }
31
+
32
+ // ==== 通用小工具 ====
33
+
34
+ function sha256File(file) {
35
+ return new Promise((resolve, reject) => {
36
+ const h = crypto.createHash("sha256");
37
+ const rs = fs.createReadStream(file);
38
+ rs.on("data", (d) => h.update(d));
39
+ rs.on("error", reject);
40
+ rs.on("end", () => resolve(h.digest("hex")));
41
+ });
42
+ }
43
+
44
+ function sftpStat(sftp, p, what) {
45
+ return new Promise((resolve, reject) => {
46
+ sftp.stat(p, (err, info) => {
47
+ if (err) {
48
+ // code 2 = SSH_FX_NO_SUCH_FILE
49
+ if (err.code === 2) resolve(null);
50
+ else reject(tErr(`${what || "远程 stat"} 失败: ${err.message || err.code || err}`));
51
+ } else {
52
+ resolve(info);
53
+ }
54
+ });
55
+ });
56
+ }
57
+
58
+ function sftpUnlink(sftp, p, what) {
59
+ return new Promise((resolve, reject) => {
60
+ sftp.unlink(p, (err) => {
61
+ if (err && err.code !== 2) reject(tErr(`删除${what || "远程文件"}失败 [${p}]: ${err.message || err.code}`));
62
+ else resolve();
63
+ });
64
+ });
65
+ }
66
+
67
+ function sftpRename(sftp, from, to) {
68
+ return new Promise((resolve, reject) => {
69
+ sftp.rename(from, to, (err) => {
70
+ if (err) reject(tErr(`改名 ${from} → ${to} 失败: ${err.message || err.code || err}`));
71
+ else resolve();
72
+ });
73
+ });
74
+ }
75
+
76
+ // ---- fast 通道(ssh2 内建的并行分块读写,吞吐比 ReadStream/WriteStream 高数倍)----
77
+
78
+ const FAST_CONCURRENCY = 32;
79
+
80
+ function sftpFastPut(sftp, localPath, remotePath, onProgress) {
81
+ return new Promise((resolve, reject) => {
82
+ sftp.fastPut(localPath, remotePath, {
83
+ concurrency: FAST_CONCURRENCY,
84
+ step: onProgress ? (_t, cur, total) => { try { onProgress(cur, total); } catch (_) {} } : undefined,
85
+ }, (err) => (err ? reject(err) : resolve()));
86
+ });
87
+ }
88
+
89
+ function sftpFastGet(sftp, remotePath, localPath, onProgress) {
90
+ return new Promise((resolve, reject) => {
91
+ sftp.fastGet(remotePath, localPath, {
92
+ concurrency: FAST_CONCURRENCY,
93
+ step: onProgress ? (_t, cur, total) => { try { onProgress(cur, total); } catch (_) {} } : undefined,
94
+ }, (err) => (err ? reject(err) : resolve()));
95
+ });
96
+ }
97
+
98
+ /**
99
+ * 带进度回调与停滞看门狗的双向泵:把 rs 泵到 ws。
100
+ * sentBytes() 返回当前已传字节数(调用方按方向提供);返回 Promise,随 ws close 正常收尾。
101
+ */
102
+ function pump(rs, ws, { sentBytes, onProgress, watchdogMsg }) {
103
+ return new Promise((resolve, reject) => {
104
+ let done = false;
105
+ let lastTickSent = -1;
106
+ let lastTickAt = 0;
107
+ let lastProgressAt = 0;
108
+ let lastProgressPrintAt = Date.now();
109
+ const finish = (fn, e) => {
110
+ if (done) return;
111
+ done = true;
112
+ clearInterval(timer);
113
+ fn(e);
114
+ };
115
+ const fail = (e) => {
116
+ finish(reject, e);
117
+ try { rs.destroy(); } catch (_) {}
118
+ try { ws.destroy(); } catch (_) {}
119
+ };
120
+ const tick = () => {
121
+ const sent = sentBytes();
122
+ const now = Date.now();
123
+ if (lastTickSent >= 0 && now - lastTickAt > STALL_TIMEOUT_MS && sent === lastTickSent) {
124
+ fail(tErr(`传输停滞超时(${STALL_TIMEOUT_MS / 1000}s 无数据增量),已中止`));
125
+ return;
126
+ }
127
+ lastTickSent = sent;
128
+ lastTickAt = now;
129
+ if (onProgress && sent !== lastProgressAt && now - lastProgressPrintAt > PROGRESS_INTERVAL_MS) {
130
+ lastProgressAt = sent;
131
+ lastProgressPrintAt = now;
132
+ onProgress(sent);
133
+ }
134
+ };
135
+ const timer = setInterval(tick, 1000);
136
+ rs.on("error", fail);
137
+ ws.on("error", fail);
138
+ ws.on("close", () => { if (!done) { done = true; clearInterval(timer); resolve(); } });
139
+ rs.pipe(ws);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 远端 sha256(file_transfer.verify="sha256" 时使用)。
145
+ * 命令必须先过 validateCmd 白名单(要求 allowed_command_prefixes 含 "sha256sum "),
146
+ * 失败时给出可操作的指引,绝不静默绕过白名单。
147
+ */
148
+ function remoteSha256(shell, conf, p) {
149
+ let cmd;
150
+ try {
151
+ cmd = validateCmd(`sha256sum ${shellQuote(p)}`, conf);
152
+ } catch (e) {
153
+ throw tErr(
154
+ `${e.message} — file_transfer.verify="sha256" 需要把 "sha256sum " 加入 allowed_command_prefixes;` +
155
+ `敏感路径(shadow/密钥等)不在散列校验范围`
156
+ );
157
+ }
158
+ return shell.execOnce(cmd).then(({ code, stdout, stderr }) => {
159
+ if (code !== 0) {
160
+ throw tErr(`远端 sha256sum 执行失败(exit ${code}): ${(stderr || stdout).trim().slice(0, 300)}`);
161
+ }
162
+ const m = stdout.match(/^([0-9a-fA-F]{64})\s/m);
163
+ if (!m) throw tErr("远端 sha256sum 输出无法解析");
164
+ return m[1].toLowerCase();
165
+ });
166
+ }
167
+
168
+ // ==== 前置校验(不经网络)====
169
+
170
+ /**
171
+ * 传输意图校验:开关/白名单/本地文件/远程路径/flag 互斥。
172
+ * 任何失败抛"拒绝"类错误(调用方按退出码 2 处理并审计)。
173
+ * 返回 {tool, flags, tc, local, remote, total}。
174
+ */
175
+ function prepareTransfer(conf, host, tool, args = {}) {
176
+ const flags = resolveTransferFlags(args);
177
+ const tc = transferConf(conf);
178
+ if (tool === "upload") {
179
+ if (!tc.upload.enabled) {
180
+ throw tErr(
181
+ '拒绝: 文件上传默认禁用(安全默认)。请在本机配置文件中增加 ' +
182
+ '"file_transfer": {"upload": {"enabled": true, "allowed_path_prefixes": ["/目录/"]}} 后重试'
183
+ );
184
+ }
185
+ if (!args.local) throw tErr("拒绝: 缺少本地文件路径(第一个参数)");
186
+ const local = path.resolve(args.local);
187
+ let st;
188
+ try { st = fs.statSync(local); } catch (_) {
189
+ throw tErr(`拒绝: 本地文件不存在 [${local}]`);
190
+ }
191
+ if (!st.isFile()) throw tErr(`拒绝: 本地路径不是文件 [${local}]`);
192
+ const remote = validateRemotePath(args.remote, "upload", conf);
193
+ return { tool, flags, tc, local, remote, total: st.size };
194
+ }
195
+ if (tool === "download") {
196
+ if (!args.remote) throw tErr("拒绝: 缺少远程文件路径(第一个参数)");
197
+ const remote = validateRemotePath(args.remote, "download", conf);
198
+ if (!args.local) throw tErr("拒绝: 缺少本地保存路径(第二个参数)");
199
+ const local = path.resolve(args.local);
200
+ // 本地正式目标已存在属"拒绝"级冲突(无需连接即可判定);--resume 只接半成品,不视为冲突
201
+ let formal = null;
202
+ try { formal = fs.statSync(local); } catch (_) {}
203
+ if (formal && !flags.resume && !flags.overwrite) {
204
+ throw tErr(`拒绝: 本地目标已存在 [${local}]。加 --overwrite 重传,或 --resume 续传半成品`);
205
+ }
206
+ // 本地半成品已存在且未给 --resume/--overwrite → 同为拒绝级(引导用户明确续传或重传意图)
207
+ let partL = null;
208
+ try { partL = fs.statSync(partPath(local)); } catch (_) {}
209
+ if (partL && !flags.resume && !flags.overwrite) {
210
+ throw tErr(`拒绝: 检测到未完成的传输 ${partPath(local)}。加 --resume 续传,或 --overwrite 删除后重传`);
211
+ }
212
+ return { tool, flags, tc, remote, local };
213
+ }
214
+ throw tErr(`未知传输方向 [${tool}]`);
215
+ }
216
+
217
+ // ==== 上传 ====
218
+
219
+ async function uploadFile(shell, conf, plan, onProgress) {
220
+ const startedAt = Date.now();
221
+ const { flags, tc } = plan;
222
+ const local = plan.local;
223
+ const remote = plan.remote;
224
+ const part = partPath(remote);
225
+ const st = fs.statSync(local);
226
+ const total = st.size;
227
+
228
+ const sftp = await shell.openSftp();
229
+
230
+ // 冲突裁决:正式目标 / 半成品 / flags
231
+ const remoteInfo = await sftpStat(sftp, remote, "远程目标");
232
+ if (remoteInfo) {
233
+ if (flags.resume) {
234
+ throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;远程正式目标已存在 [${remote}](覆盖请用 --overwrite)`);
235
+ }
236
+ if (!flags.overwrite) {
237
+ throw tErr(`拒绝: 远程目标已存在 [${remote}]。加 --overwrite 重传,或 --resume 续传半成品`);
238
+ }
239
+ await sftpUnlink(sftp, remote, "远程正式目标");
240
+ }
241
+
242
+ const partInfo = await sftpStat(sftp, part, "半成品");
243
+ let offset = 0;
244
+ let resumed = false;
245
+ if (partInfo) {
246
+ if (!flags.overwrite && !flags.resume) {
247
+ throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
248
+ }
249
+ if (flags.overwrite) {
250
+ await sftpUnlink(sftp, part, "半成品");
251
+ } else {
252
+ const r = computeResumeOffset(partInfo.size, total);
253
+ if (r.mode === "ready") {
254
+ // 半成品与源大小一致:可选 sha 校验后直接改名(免重传)
255
+ if (tc.verify === "sha256") {
256
+ const localSha = await sha256File(local);
257
+ const remoteSha = await remoteSha256(shell, conf, part);
258
+ if (remoteSha !== localSha) {
259
+ await sftpUnlink(sftp, part, "不一致的半成品");
260
+ throw tErr(`校验失败: 半成品 sha256 与本地源不一致,已删除 ${part}(请用 --overwrite 重传)`);
261
+ }
262
+ }
263
+ await sftpRename(sftp, part, remote);
264
+ return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
265
+ }
266
+ if (r.mode === "restart") {
267
+ await sftpUnlink(sftp, part, "与源大小不符的半成品");
268
+ } else {
269
+ offset = r.offset;
270
+ resumed = true;
271
+ }
272
+ }
273
+ }
274
+
275
+ let localSha = null;
276
+ if (tc.verify === "sha256") {
277
+ localSha = await sha256File(local);
278
+ }
279
+
280
+ // 推送:offset===0 走 fastPut(并行分块,吞吐比 stream 高数倍),续传用 stream 管道定位追加
281
+ await (async () => {
282
+ if (offset === 0 && total > 0) {
283
+ try {
284
+ await sftpFastPut(sftp, local, part, onProgress);
285
+ return;
286
+ } catch (e) {
287
+ // fastPut 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
288
+ }
289
+ }
290
+ const rs = fs.createReadStream(local, { start: offset });
291
+ const ws = sftp.createWriteStream(part, offset > 0 ? { flags: "r+", start: offset } : { flags: "w" });
292
+ await pump(rs, ws, {
293
+ sentBytes: () => offset + rs.bytesRead,
294
+ onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
295
+ watchdogMsg: "上传",
296
+ });
297
+ })();
298
+
299
+ // 完整性校验(对 part 散列,改名字节即与校验字节一致)
300
+ if (tc.verify === "sha256") {
301
+ const remoteSha = await remoteSha256(shell, conf, part);
302
+ if (remoteSha !== localSha) {
303
+ await sftpUnlink(sftp, part, "校验失败的半成品");
304
+ throw tErr(`校验失败: 上传内容 sha256 与本地源不一致(网络或源文件变动),已删除半成品`);
305
+ }
306
+ }
307
+
308
+ await sftpRename(sftp, part, remote);
309
+ return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
310
+ }
311
+
312
+ // ==== 下载 ====
313
+
314
+ async function downloadFile(shell, conf, plan, onProgress) {
315
+ const startedAt = Date.now();
316
+ const { flags, tc } = plan;
317
+ const remote = plan.remote;
318
+ const local = plan.local;
319
+ const part = partPath(local);
320
+
321
+ const dir = path.dirname(local);
322
+ if (!fs.existsSync(dir)) throw tErr(`拒绝: 本地目标目录不存在 [${dir}]`);
323
+
324
+ const sftp = await shell.openSftp();
325
+
326
+ const remoteInfo = await sftpStat(sftp, remote, "远程文件");
327
+ if (!remoteInfo) throw tErr(`拒绝: 远程文件不存在或不可读 [${remote}]`);
328
+ if (remoteInfo.isDirectory()) throw tErr(`拒绝: 远程目标是目录,传输仅支持单文件 [${remote}]`);
329
+ const total = Number(remoteInfo.size || 0);
330
+
331
+ // 本地正式目标冲突裁决
332
+ let localFormal = null;
333
+ try { localFormal = fs.statSync(local); } catch (_) {}
334
+ if (localFormal) {
335
+ if (flags.resume) {
336
+ throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;本地正式目标已存在 [${local}](覆盖请用 --overwrite)`);
337
+ }
338
+ if (!flags.overwrite) {
339
+ throw tErr(`拒绝: 本地目标已存在 [${local}]。加 --overwrite 重传,或 --resume 续传半成品`);
340
+ }
341
+ fs.unlinkSync(local);
342
+ }
343
+
344
+ // 本地半成品裁决
345
+ let partSt = null;
346
+ try { partSt = fs.statSync(part); } catch (_) {}
347
+ let offset = 0;
348
+ let resumed = false;
349
+ if (partSt) {
350
+ if (!flags.overwrite && !flags.resume) {
351
+ throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
352
+ }
353
+ if (flags.overwrite) {
354
+ fs.unlinkSync(part);
355
+ } else {
356
+ const r = computeResumeOffset(partSt.size, total);
357
+ if (r.mode === "ready") {
358
+ if (tc.verify === "sha256") {
359
+ const remoteSha = await remoteSha256(shell, conf, remote);
360
+ const localSha = await sha256File(part);
361
+ if (remoteSha !== localSha) {
362
+ fs.unlinkSync(part);
363
+ throw tErr(`校验失败: 半成品 sha256 与远端文件不一致,已删除 ${part}(请用 --overwrite 重传)`);
364
+ }
365
+ }
366
+ fs.renameSync(part, local);
367
+ return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
368
+ }
369
+ if (r.mode === "restart") {
370
+ fs.unlinkSync(part);
371
+ } else {
372
+ offset = r.offset;
373
+ resumed = true;
374
+ }
375
+ }
376
+ }
377
+
378
+ // 远端整体散列(可选):与下载内容比对,可捕获"传输期间远端文件被改"
379
+ let remoteSha = null;
380
+ if (tc.verify === "sha256") remoteSha = await remoteSha256(shell, conf, remote);
381
+
382
+ // 拉取:offset===0 走 fastGet(并行分块,吞吐比 stream 高数倍),续传用 stream 管道追加
383
+ await (async () => {
384
+ if (offset === 0 && total > 0) {
385
+ try {
386
+ await sftpFastGet(sftp, remote, part, onProgress);
387
+ return;
388
+ } catch (e) {
389
+ // fastGet 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
390
+ }
391
+ }
392
+ const rs = sftp.createReadStream(remote, offset > 0 ? { start: offset } : { flags: "r" });
393
+ const ws = fs.createWriteStream(part, resumed ? { flags: "a" } : { flags: "w" });
394
+ await pump(rs, ws, {
395
+ sentBytes: () => offset + ws.bytesWritten,
396
+ onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
397
+ watchdogMsg: "下载",
398
+ });
399
+ })();
400
+
401
+ // 完整性校验
402
+ if (tc.verify === "sha256") {
403
+ const localSha = await sha256File(part);
404
+ if (localSha !== remoteSha) {
405
+ fs.unlinkSync(part);
406
+ throw tErr(`校验失败: 下载内容 sha256 与远端文件不一致(传输期间文件可能被改),已删除半成品`);
407
+ }
408
+ }
409
+
410
+ fs.renameSync(part, local);
411
+ return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
412
+ }
413
+
414
+ module.exports = {
415
+ prepareTransfer,
416
+ uploadFile,
417
+ downloadFile,
418
+ // 仅供测试与进度展示
419
+ pump,
420
+ remoteSha256,
421
+ sha256File,
422
+ };
package/src/whitelist.js CHANGED
@@ -224,6 +224,107 @@ function validateBin(bin, conf) {
224
224
  return b;
225
225
  }
226
226
 
227
+ // ==== 文件传输(upload / download)校验 ====
228
+ // 上传是写操作:默认全禁,须显式 file_transfer.upload.enabled=true 且配置 allowed_path_prefixes。
229
+ // 下载不做读白名单(决策:放开),仅保留内置敏感路径防呆(shadow / .ssh 目录族),配置可追加黑名单。
230
+
231
+ function transferConf(conf) {
232
+ const ft = (conf && conf.file_transfer) || {};
233
+ const up = ft.upload || {};
234
+ const dl = ft.download || {};
235
+ return {
236
+ upload: {
237
+ enabled: up.enabled === true,
238
+ allowed_path_prefixes: Array.isArray(up.allowed_path_prefixes)
239
+ ? up.allowed_path_prefixes.filter((x) => typeof x === "string" && x)
240
+ : [],
241
+ },
242
+ download: {
243
+ blocked_paths: Array.isArray(dl.blocked_paths)
244
+ ? dl.blocked_paths.filter((x) => typeof x === "string" && x)
245
+ : [],
246
+ },
247
+ verify: ft.verify === "sha256" ? "sha256" : "size",
248
+ };
249
+ }
250
+
251
+ // SFTP 路径本身是纯数据,但会进入审计日志与(verify:sha256 时)远端 sha256sum 命令行,
252
+ // 因此拒绝控制字符与 shell 元字符;空格等常规字符允许(拼接处一律 shellQuote)。
253
+ const REMOTE_PATH_RE = /^[A-Za-z0-9 ._\-/:@+=,%()]+$/;
254
+
255
+ // 传输内置防呆(读/写共用):shadow 族文件 + 任意 .ssh 目录(含 /root/.ssh、/home/*/.ssh),不可配置关闭。
256
+ // 下载拒绝 = 不允许拉取;上传拒绝 = 不允许覆盖(防误写密钥)。
257
+ function isSensitivePath(p, extraBlocked) {
258
+ if (p.startsWith("/etc/shadow")) return true;
259
+ if (p.startsWith("/etc/ssh/")) return true;
260
+ if (p.includes("/.ssh/") || p.endsWith("/.ssh")) return true;
261
+ for (const sp of extraBlocked || []) {
262
+ if (p.startsWith(sp)) return true;
263
+ }
264
+ return false;
265
+ }
266
+ const isDownloadBlocked = isSensitivePath; // 兼容旧名
267
+
268
+ /**
269
+ * 校验传输远程路径。mode: "upload"(须命中上传白名单)| "download"(仅黑名单防呆)。
270
+ * 黑名单 = 内置防呆(shadow/.ssh 族)⊕ file_transfer.download.blocked_paths ⊕ 全局 blocked_paths
271
+ * (读白名单放开不等于敏感黑名单放开——用户手工维护的 blocked_paths 对传输同样生效)。
272
+ * 返回 posix normalize 后的路径(/a/./b、/a//b 归一后再比对,防绕过)。
273
+ */
274
+ function validateRemotePath(raw, mode, conf) {
275
+ const s = String(raw || "").trim();
276
+ if (!s.startsWith("/")) throw new Error(`拒绝: 传输路径必须是绝对路径 [${s}]`);
277
+ if (!REMOTE_PATH_RE.test(s)) {
278
+ throw new Error(`拒绝: 传输路径含非法字符(控制字符 / shell 元字符)[${s}]`);
279
+ }
280
+ const p = path.posix.normalize(s);
281
+ const tc = transferConf(conf);
282
+ const extraBlocked = [
283
+ ...(tc.download.blocked_paths || []),
284
+ ...(Array.isArray(conf.blocked_paths) ? conf.blocked_paths : []),
285
+ ];
286
+ if (mode === "upload") {
287
+ const prefixes = tc.upload.allowed_path_prefixes;
288
+ if (!prefixes.length) {
289
+ throw new Error(`拒绝: 上传路径白名单未配置(file_transfer.upload.allowed_path_prefixes),未配置时上传一律拒绝 [${p}]`);
290
+ }
291
+ if (!prefixes.some((pfx) => p.startsWith(pfx))) {
292
+ throw new Error(`拒绝: 上传路径不在 file_transfer.upload.allowed_path_prefixes 白名单 [${p}]`);
293
+ }
294
+ // 上传内置防呆:即使命中写白名单,也不允许覆盖 .ssh / shadow / blocked_paths 族文件
295
+ if (isSensitivePath(p, extraBlocked)) {
296
+ throw new Error(`拒绝: 上传命中敏感路径保护 [${p}](防呆:shadow / .ssh / blocked_paths 族不可覆盖)`);
297
+ }
298
+ } else {
299
+ if (isSensitivePath(p, extraBlocked)) {
300
+ throw new Error(`拒绝: 下载命中敏感路径保护 [${p}](内置防呆 shadow/.ssh + 配置黑名单;可用 file_transfer.download.blocked_paths 追加)`);
301
+ }
302
+ }
303
+ return p;
304
+ }
305
+
306
+ // 半成品临时文件名:传输完成才原子改名,正式目标出现即视为完整文件
307
+ function partPath(target) {
308
+ return `${target}.rrs-part`;
309
+ }
310
+
311
+ /** --overwrite / --resume 解析与互斥校验 */
312
+ function resolveTransferFlags(opts = {}) {
313
+ const overwrite = opts.overwrite === true;
314
+ const resume = opts.resume === true;
315
+ if (overwrite && resume) {
316
+ throw new Error("拒绝: --overwrite 与 --resume 互斥(--overwrite 从头重传;--resume 仅续传半成品)");
317
+ }
318
+ return { overwrite, resume };
319
+ }
320
+
321
+ /** 断点续传决策:半成品字节与目标大小的三种关系 → 不传/续传/重来 */
322
+ function computeResumeOffset(partSize, total) {
323
+ if (partSize === total) return { mode: "ready", offset: total }; // 传完整但未改名,直接 rename
324
+ if (partSize > total) return { mode: "restart", offset: 0 }; // 源文件已变更,删除半成品重传
325
+ return { mode: "continue", offset: partSize };
326
+ }
327
+
227
328
  module.exports = {
228
329
  validateCmd,
229
330
  validatePath,
@@ -232,4 +333,10 @@ module.exports = {
232
333
  validateBin,
233
334
  containerExecConf,
234
335
  DEFAULT_CONTAINER_EXEC,
336
+ transferConf,
337
+ validateRemotePath,
338
+ isSensitivePath,
339
+ partPath,
340
+ resolveTransferFlags,
341
+ computeResumeOffset,
235
342
  };