@zicolasjac-ai/remote-readonly-ssh 1.4.0 → 1.4.2
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 +55 -2
- package/bin/rrs.js +103 -1
- package/config.example.json +12 -1
- package/package.json +1 -1
- package/skills/remote-readonly-ssh/SKILL.md +32 -6
- package/src/config.js +49 -0
- package/src/jumpshell.js +41 -0
- package/src/transfer.js +492 -0
- package/src/whitelist.js +107 -0
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 直连或堡垒机 koko 网关;shadow/.ssh 类路径防呆) |
|
|
72
|
+
| `upload <本地> <远程>` | 上传单文件(写操作,**默认禁用**:须 `file_transfer.upload.enabled=true` + 独立写路径白名单) |
|
|
71
73
|
|
|
72
74
|
## 配置
|
|
73
75
|
|
|
@@ -76,13 +78,14 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
76
78
|
| 键 | 说明 |
|
|
77
79
|
|-----|------|
|
|
78
80
|
| `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth, direct?}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`direct: true` 表示**直连裸机**(跳过堡垒机菜单,适合无堡垒机的服务器;命令只读校验与堡垒机模式完全一致)。`list-tools` 只显示模式与主机,不显示任何凭据 |
|
|
79
|
-
| `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP
|
|
81
|
+
| `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?, sftp_root?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP);`sftp_root` 可选——koko 资产树资产目录(如 `Default/业务分组/10.0.0.3`),文件传输在菜单资产模式下用于路径映射,未配置时自动探测(命中多个要求显式声明)。**多台主机可共用同一认证组(N:1)** |
|
|
80
82
|
| `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
|
|
81
83
|
| `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
|
|
82
84
|
| `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
|
|
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,56 @@ 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
|
+
- **双通道**:
|
|
103
|
+
- `direct: true` 直连认证组 → SFTP 通道即目标机**真实文件系统**;
|
|
104
|
+
- 普通堡垒机菜单资产 → 走 JumpServer **koko 的 SFTP 文件传输入口**(与 SSH 菜单同端口同一套凭据,无需进菜单)。语义路径自动映射进该资产在 koko 资产树中的目录(如 `Default/资产组/10.0.0.3`):默认**自动探测**(逐层 readdir 找名为资产 IP 的目录,命中多个则要求显式配置);特殊命名可在 `target_hosts` 条目加 `"sftp_root": "Default/资产组/10.0.0.3"` 显式声明。
|
|
105
|
+
- koko 模式暂不支持 `verify: "sha256"`(远端散列需直达 exec 通道),配置为 sha256 时明确报错。
|
|
106
|
+
- **大小不设上限**(1GB 级为常规场景;速度受链路带宽约束)。
|
|
107
|
+
- 目标(两端)**已存在即拒绝**:`--overwrite` 从头重传;`--resume` 断点续传未完成半成品 `<目标>.rrs-part`(互斥,续传前校验两端字节数一致)。产物先写半成品、校验后原子改名,**见到正式文件即视为完整**。
|
|
108
|
+
- 单文件传输;不支持目录递归、不支持 `docker exec` 容器内文件。
|
|
109
|
+
- 上传需 opt-in + 独立写路径白名单(语义路径层校验,koko 模式同样生效);上传/下载不受 `(语义路径) 之外的资产树越界`——映射后被强制限定在该资产目录内。
|
|
110
|
+
|
|
111
|
+
**下载**(默认可用):
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
rrs download /var/log/app.log "E:\logs\app.log" --host 10.0.0.2 --json
|
|
115
|
+
rrs download /var/log/app.log "E:\logs\app.log" --resume --host 10.0.0.2 # 续传
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
不做读白名单限制,但敏感路径防呆生效:内置 `/etc/shadow` 族、任意 `/.ssh/` 目录/密钥一律拒绝,且你的 `blocked_paths` 全局黑名单与 `file_transfer.download.blocked_paths` 追加同样生效(读白名单放开 ≠ 敏感黑名单放开)。
|
|
119
|
+
|
|
120
|
+
**上传**(opt-in,默认拒绝):
|
|
121
|
+
|
|
122
|
+
在 `~/.rrs/config.json` 显式开启(写路径与读路径分开管,不上读白名单的当):
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
"file_transfer": {
|
|
126
|
+
"upload": {
|
|
127
|
+
"enabled": true,
|
|
128
|
+
"allowed_path_prefixes": ["/opt/upload/", "/data/deploy/"]
|
|
129
|
+
},
|
|
130
|
+
"download": { "blocked_paths": [] },
|
|
131
|
+
"verify": "size"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- 未配置 `allowed_path_prefixes` 时**上传一律拒绝**(避免"开了开关即全盘可写")。
|
|
140
|
+
- 远程路径拒绝控制字符与 shell 元字符;`--overwrite` / `--resume` 语义同上。
|
|
141
|
+
|
|
142
|
+
**完整性校验**:默认核两端**字节大小**(零开销)。需要防"传输期间文件被改"时把 `verify` 设为 `"sha256"`——远端会跑 `sha256sum`,因此须把 `"sha256sum "` 加入 `allowed_command_prefixes`(黑白名单语义不受影响,缺白名单会明确报错而非静默绕过)。
|
|
143
|
+
|
|
144
|
+
`--json` 输出含 `bytes` / `elapsed_ms` / `resumed` / `verify` 等元数据;终端下进度打在 stderr,不污染 JSON。
|
|
145
|
+
|
|
93
146
|
## Agent Skills
|
|
94
147
|
|
|
95
148
|
本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。安装后用 `rrs setup skills` 一键分发到各 agent 的 skills 目录:
|
package/bin/rrs.js
CHANGED
|
@@ -10,6 +10,7 @@ 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");
|
|
13
14
|
|
|
14
15
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
15
16
|
|
|
@@ -24,13 +25,17 @@ function printUsage() {
|
|
|
24
25
|
["dir <path>", "列目录(路径白名单)"],
|
|
25
26
|
["bin-version <bin>", "查二进制版本(二进制白名单)"],
|
|
26
27
|
["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
|
|
28
|
+
["download <远程> <本地>", "下载单文件(SFTP,仅 direct 主机;内置敏感路径防呆)"],
|
|
29
|
+
["upload <本地> <远程>", "上传单文件(SFTP,默认禁用需配置开启 + 写路径白名单)"],
|
|
27
30
|
["setup skills [--agent <n>]", "把 SKILL.md 装到 agents(实体) + opencode/claude/qoder(软链接)"],
|
|
28
31
|
["setup uninstall skills [--agent <n>]", "移除上述目录的 skill"],
|
|
29
32
|
];
|
|
30
33
|
console.log("rrs — CLI and Agent Skill for safe read-only SSH inspection\n");
|
|
31
34
|
console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]");
|
|
35
|
+
console.log(" rrs download <远程绝对路径> <本地绝对路径> [--overwrite|--resume] [--host <ip>]");
|
|
36
|
+
console.log(" rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--host <ip>]");
|
|
32
37
|
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");
|
|
38
|
+
console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); --overwrite 重传; --resume 断点续传; -v/--version 查看版本\n");
|
|
34
39
|
for (const [k, v] of cmds) {
|
|
35
40
|
console.log(` ${k.padEnd(40)} ${v}`);
|
|
36
41
|
}
|
|
@@ -48,6 +53,8 @@ function parseArgs(argv) {
|
|
|
48
53
|
config: null,
|
|
49
54
|
version: false,
|
|
50
55
|
json: false,
|
|
56
|
+
overwrite: false,
|
|
57
|
+
resume: false,
|
|
51
58
|
};
|
|
52
59
|
const rest = [];
|
|
53
60
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -56,6 +63,8 @@ function parseArgs(argv) {
|
|
|
56
63
|
else if (a === "--config") opts.config = argv[++i];
|
|
57
64
|
else if (a === "--agent") opts.agent = argv[++i];
|
|
58
65
|
else if (a === "--dry-run") opts.dryRun = true;
|
|
66
|
+
else if (a === "--overwrite") opts.overwrite = true;
|
|
67
|
+
else if (a === "--resume") opts.resume = true;
|
|
59
68
|
else if (a === "-h" || a === "--help") { opts.mode = "tool"; opts.tool = "help"; }
|
|
60
69
|
else if (a === "-v" || a === "--version") opts.version = true;
|
|
61
70
|
else if (a === "--json") opts.json = true;
|
|
@@ -74,6 +83,7 @@ function parseArgs(argv) {
|
|
|
74
83
|
}
|
|
75
84
|
opts.tool = opts.tool || rest[0] || "help";
|
|
76
85
|
opts.arg = rest[1] || null;
|
|
86
|
+
opts.arg2 = rest[2] || null;
|
|
77
87
|
return opts;
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -188,6 +198,10 @@ function listTools(conf) {
|
|
|
188
198
|
console.log(`允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
|
|
189
199
|
console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
|
|
190
200
|
console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
|
|
201
|
+
const ft = conf.file_transfer || {};
|
|
202
|
+
const upEnabled = ft.upload && ft.upload.enabled === true;
|
|
203
|
+
const upPrefixes = (ft.upload && ft.upload.allowed_path_prefixes || []).join(", ") || "(未配置=全禁)";
|
|
204
|
+
console.log(`文件传输: 下载=可用(内置敏感路径防呆) | 上传=${upEnabled ? `启用(写路径前缀: ${upPrefixes})` : "禁用(file_transfer.upload.enabled=true 开启)"}`);
|
|
191
205
|
console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
|
|
192
206
|
console.log(`审计日志: ${path.join(configDir(), "audit.log")}`);
|
|
193
207
|
}
|
|
@@ -198,6 +212,93 @@ function emitResult(opts, data) {
|
|
|
198
212
|
else console.log(data.output);
|
|
199
213
|
}
|
|
200
214
|
|
|
215
|
+
// ==== 文件传输(upload / download)====
|
|
216
|
+
|
|
217
|
+
function fmtBytes(n) {
|
|
218
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
219
|
+
let v = Number(n) || 0;
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
222
|
+
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function fmtDur(ms) {
|
|
226
|
+
const s = Math.round(ms / 1000);
|
|
227
|
+
if (s < 60) return `${s}s`;
|
|
228
|
+
const m = Math.floor(s / 60);
|
|
229
|
+
return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 进度只打到 stderr(不污染 --json 的 stdout),且仅终端场景显示 */
|
|
233
|
+
function makeProgress(opts) {
|
|
234
|
+
if (opts.json || !process.stderr.isTTY) return null;
|
|
235
|
+
const startedAt = Date.now();
|
|
236
|
+
return (sent, total) => {
|
|
237
|
+
const pct = total > 0 ? ((sent / total) * 100).toFixed(1) : "-";
|
|
238
|
+
const speed = sent / 1048576 / Math.max(0.25, (Date.now() - startedAt) / 1000);
|
|
239
|
+
process.stderr.write(` 已传 ${fmtBytes(sent)} / ${fmtBytes(total)}(${pct}%,~${speed.toFixed(1)} MiB/s) \r`);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function runTransfer(opts, conf) {
|
|
244
|
+
const tool = opts.tool;
|
|
245
|
+
const isUpload = tool === "upload";
|
|
246
|
+
// 语义:upload <本地> <远程>;download <远程> <本地>
|
|
247
|
+
const args = isUpload
|
|
248
|
+
? { local: opts.arg, remote: opts.arg2, overwrite: opts.overwrite, resume: opts.resume }
|
|
249
|
+
: { remote: opts.arg, local: opts.arg2, overwrite: opts.overwrite, resume: opts.resume };
|
|
250
|
+
|
|
251
|
+
// ---- 静态校验(拒绝类:退出码 2)----
|
|
252
|
+
let host = null;
|
|
253
|
+
let cmds = [];
|
|
254
|
+
let plan;
|
|
255
|
+
try {
|
|
256
|
+
host = validateHost(opts.host || Object.keys(conf.target_hosts || {})[0], conf);
|
|
257
|
+
cmds = [isUpload
|
|
258
|
+
? `upload ${args.local || "(无)"} -> ${args.remote || "(无)"}`
|
|
259
|
+
: `download ${args.remote || "(无)"} -> ${args.local || "(无)"}`];
|
|
260
|
+
plan = transfer.prepareTransfer(conf, host, tool, args);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
audit(conf, tool, host || "", cmds, false, e.message);
|
|
263
|
+
emitResult(opts, { ok: false, tool, host: host || "", error: e.message, kind: "拒绝" });
|
|
264
|
+
process.exit(2);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- 连接与执行(错误类:退出码 1)----
|
|
268
|
+
const shell = new JumpShell(conf, host);
|
|
269
|
+
try {
|
|
270
|
+
await shell.connect(); // direct 连接即到目标机,无需菜单 login
|
|
271
|
+
const onProgress = makeProgress(opts);
|
|
272
|
+
const result = isUpload
|
|
273
|
+
? await transfer.uploadFile(shell, conf, plan, onProgress)
|
|
274
|
+
: await transfer.downloadFile(shell, conf, plan, onProgress);
|
|
275
|
+
shell.close();
|
|
276
|
+
try { if (onProgress) process.stderr.write("\n"); } catch (_) {}
|
|
277
|
+
|
|
278
|
+
const speedBps = result.bytes / Math.max(0.001, result.elapsedMs / 1000);
|
|
279
|
+
const resumeTag = result.resumed ? "(断点续传)" : "";
|
|
280
|
+
const viaTag = plan.mode === "koko" ? "(koko 资产树直传)" : "";
|
|
281
|
+
const verifyTag = result.verify === "sha256" ? ",sha256 校验通过" : "";
|
|
282
|
+
const text = `${isUpload ? "上传" : "下载"}完成: ${plan.local} ↔ ${plan.remote} | ${fmtBytes(result.bytes)} 耗时 ${fmtDur(result.elapsedMs)} 均 ${(speedBps / 1048576).toFixed(1)} MiB/s${resumeTag}${viaTag}${verifyTag}`;
|
|
283
|
+
audit(conf, tool, host, cmds, true,
|
|
284
|
+
`bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite} via=${plan.mode}`);
|
|
285
|
+
emitResult(opts, {
|
|
286
|
+
ok: true, tool, host,
|
|
287
|
+
local: plan.local, remote: plan.remote,
|
|
288
|
+
bytes: result.bytes, resumed: result.resumed,
|
|
289
|
+
elapsed_ms: result.elapsedMs, verify: result.verify,
|
|
290
|
+
overwrite: plan.flags.overwrite, via: plan.mode,
|
|
291
|
+
output: text,
|
|
292
|
+
});
|
|
293
|
+
} catch (e) {
|
|
294
|
+
shell.close();
|
|
295
|
+
try { if (!opts.json && process.stderr.isTTY) process.stderr.write("\n"); } catch (_) {}
|
|
296
|
+
audit(conf, tool, host, cmds, false, e.message);
|
|
297
|
+
emitResult(opts, { ok: false, tool, host, error: e.message, kind: "错误" });
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
201
302
|
async function main() {
|
|
202
303
|
const opts = parseArgs(process.argv.slice(2));
|
|
203
304
|
if (opts.version) {
|
|
@@ -224,6 +325,7 @@ async function main() {
|
|
|
224
325
|
}
|
|
225
326
|
|
|
226
327
|
if (opts.tool === "list-tools") { listTools(conf); return; }
|
|
328
|
+
if (opts.tool === "upload" || opts.tool === "download") { await runTransfer(opts, conf); return; }
|
|
227
329
|
|
|
228
330
|
let host, cmds;
|
|
229
331
|
try {
|
package/config.example.json
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
"target_hosts": {
|
|
34
34
|
"10.0.0.1": { "profile": "profile_a", "keyword": "10.0.0" },
|
|
35
|
-
"10.0.0.2": { "profile": "profile_a", "keyword": "10.0.0" }
|
|
35
|
+
"10.0.0.2": { "profile": "profile_a", "keyword": "10.0.0" },
|
|
36
|
+
"10.0.0.3": { "profile": "profile_b", "keyword": "10.0.0", "sftp_root": "Default/业务分组/10.0.0.3" }
|
|
36
37
|
},
|
|
37
38
|
"audit": {
|
|
38
39
|
"enabled": true,
|
|
@@ -90,6 +91,16 @@
|
|
|
90
91
|
"/etc/",
|
|
91
92
|
"/var/log/"
|
|
92
93
|
],
|
|
94
|
+
"file_transfer": {
|
|
95
|
+
"upload": {
|
|
96
|
+
"enabled": false,
|
|
97
|
+
"allowed_path_prefixes": ["/opt/upload/"]
|
|
98
|
+
},
|
|
99
|
+
"download": {
|
|
100
|
+
"blocked_paths": []
|
|
101
|
+
},
|
|
102
|
+
"verify": "size"
|
|
103
|
+
},
|
|
93
104
|
"blocked_paths": [
|
|
94
105
|
"/etc/shadow",
|
|
95
106
|
"/etc/passwd",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
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
|
|
3
|
+
description: 通过 JumpServer 堡垒机对服务器做白名单强制的运维查询与单文件传输(sys-info / proc / ports / dir / read-file / bin-version / exec-only / download / upload)。命令默认只读、前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验;下载自带敏感路径防呆,上传默认禁用需配置显式开启;传输支持 direct 直连资产与堡垒机菜单资产(koko 资产树自动探测或 sftp_root 显式声明)。每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要查看服务器现状或与白名单服务器互传单文件时调用此 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:
|
|
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 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询,并支持对白名单主机做单文件上传/下载(SFTP):`direct: true` 直连资产走真实文件系统,普通菜单资产走 JumpServer koko 的 SFTP 文件传输入口(资产树自动探测或 `sftp_root` 显式声明)。每次调用(含被拒绝的)都写入本地审计日志。
|
|
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` 直连资产 = 真实文件系统;普通菜单资产 = **koko SFTP 网关**(连接后自动探测 koko 资产树定位该资产目录并映射语义路径;命名特殊时在 `target_hosts` 加 `"sftp_root": "模块/资产组/IP"` 显式声明;koko 模式不支持 `verify:"sha256"`,远端散列不可用)
|
|
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
|
@@ -99,6 +99,13 @@ function validateConfig(conf) {
|
|
|
99
99
|
} else if (!profiles || !profiles[entry.profile]) {
|
|
100
100
|
errors.push(`target_hosts[${ip}] 引用不存在的认证组 [${entry.profile}]`);
|
|
101
101
|
}
|
|
102
|
+
if (entry.sftp_root !== undefined) {
|
|
103
|
+
if (typeof entry.sftp_root !== "string") {
|
|
104
|
+
errors.push(`target_hosts[${ip}] sftp_root 必须是字符串(koko 资产树目录,如 "Default/资产组/${ip}")`);
|
|
105
|
+
} else if (entry.sftp_root.split("/").some((seg) => !seg || seg === "." || seg === "..")) {
|
|
106
|
+
errors.push(`target_hosts[${ip}] sftp_root 含非法路径段(空段 / "." / ".." 不允许)`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
111
|
|
|
@@ -123,6 +130,40 @@ function validateConfig(conf) {
|
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
const ft = conf.file_transfer;
|
|
134
|
+
if (ft !== undefined) {
|
|
135
|
+
if (!ft || typeof ft !== "object" || Array.isArray(ft)) {
|
|
136
|
+
errors.push("file_transfer 必须是对象 {upload: {enabled, allowed_path_prefixes}, download: {blocked_paths}, verify}");
|
|
137
|
+
} else {
|
|
138
|
+
if (ft.upload !== undefined) {
|
|
139
|
+
if (!ft.upload || typeof ft.upload !== "object" || Array.isArray(ft.upload)) {
|
|
140
|
+
errors.push("file_transfer.upload 必须是对象 {enabled, allowed_path_prefixes}");
|
|
141
|
+
} else {
|
|
142
|
+
if (ft.upload.enabled !== undefined && typeof ft.upload.enabled !== "boolean") {
|
|
143
|
+
errors.push(`file_transfer.upload.enabled 必须是布尔值(true=显式启用上传),当前: ${ft.upload.enabled}`);
|
|
144
|
+
}
|
|
145
|
+
if (ft.upload.allowed_path_prefixes !== undefined) {
|
|
146
|
+
if (!Array.isArray(ft.upload.allowed_path_prefixes) || !ft.upload.allowed_path_prefixes.every((x) => typeof x === "string" && x)) {
|
|
147
|
+
errors.push("file_transfer.upload.allowed_path_prefixes 必须是非空字符串数组(上传写入路径白名单)");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (ft.download !== undefined) {
|
|
153
|
+
if (!ft.download || typeof ft.download !== "object" || Array.isArray(ft.download)) {
|
|
154
|
+
errors.push("file_transfer.download 必须是对象 {blocked_paths}");
|
|
155
|
+
} else if (ft.download.blocked_paths !== undefined) {
|
|
156
|
+
if (!Array.isArray(ft.download.blocked_paths) || !ft.download.blocked_paths.every((x) => typeof x === "string" && x)) {
|
|
157
|
+
errors.push("file_transfer.download.blocked_paths 必须是非空字符串数组");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (ft.verify !== undefined && ft.verify !== "size" && ft.verify !== "sha256") {
|
|
162
|
+
errors.push(`file_transfer.verify 必须是 "size" 或 "sha256",当前: ${ft.verify}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
if (errors.length) throw cfgError(errors.join(";"));
|
|
127
168
|
return true;
|
|
128
169
|
}
|
|
@@ -135,6 +176,8 @@ const TOOL_PURPOSE = {
|
|
|
135
176
|
"dir": "目录结构核对",
|
|
136
177
|
"bin-version": "软件版本核对",
|
|
137
178
|
"exec-only": "只读命令核查",
|
|
179
|
+
"upload": "文件上传(写操作·opt-in)",
|
|
180
|
+
"download": "文件下载(内置敏感路径防呆)",
|
|
138
181
|
};
|
|
139
182
|
|
|
140
183
|
function describeCmd(tool, cmd) {
|
|
@@ -159,6 +202,12 @@ function describeCmd(tool, cmd) {
|
|
|
159
202
|
const m = cmd.match(/^([^\s]+)/);
|
|
160
203
|
return m ? `核对 ${m[1]} 版本信息` : "核对版本信息";
|
|
161
204
|
}
|
|
205
|
+
case "upload":
|
|
206
|
+
return "上传本地文件到服务器(写操作,须配置显式启用)";
|
|
207
|
+
case "download":
|
|
208
|
+
return "下载服务器文件到本机(黑名单防呆:shadow / .ssh)";
|
|
209
|
+
case "verify-sha256":
|
|
210
|
+
return "传输完整性校验(sha256sum,只读)";
|
|
162
211
|
default:
|
|
163
212
|
return TOOL_PURPOSE[tool] || "只读核查";
|
|
164
213
|
}
|
package/src/jumpshell.js
CHANGED
|
@@ -240,6 +240,47 @@ class JumpShell {
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* 打开 SFTP 子系统通道(文件传输专用)。两种模式:
|
|
245
|
+
* - direct: true 直连认证组 → 通道即目标机真实文件系统
|
|
246
|
+
* - 堡垒机菜单认证组 → JumpServer koko 的 SFTP 文件传输入口(资产树虚拟根),
|
|
247
|
+
* 无需菜单导航;路径映射与资产定位由 transfer 层完成。
|
|
248
|
+
* 失败通常意味着堡垒机未开放文件传输或资产未授权 SFTP,报错给出可操作指引。
|
|
249
|
+
*/
|
|
250
|
+
async openSftp() {
|
|
251
|
+
if (!this.conn) throw new Error("SSH 未连接");
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
this.conn.sftp((err, sftp) => {
|
|
254
|
+
if (err) {
|
|
255
|
+
reject(new Error(`SFTP 子系统打开失败: ${err.message}(堡垒机需开放文件传输(koko)或走 direct 直连认证组;口令账号需有读写权限)`));
|
|
256
|
+
} else {
|
|
257
|
+
resolve(sftp);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 直连模式下在目标机执行单条已校验命令并收集 stdout/stderr/exit code。
|
|
265
|
+
* 用于 file_transfer.verify: "sha256" 时跑远端 sha256sum(命令须经 validateCmd 白名单校验后传入)。
|
|
266
|
+
*/
|
|
267
|
+
execOnce(cmd) {
|
|
268
|
+
if (!this.conn) throw new Error("SSH 未连接");
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
this.conn.exec(cmd, { pty: false }, (err, stream) => {
|
|
271
|
+
if (err) return reject(mapConnError(err));
|
|
272
|
+
let out = "";
|
|
273
|
+
let errText = "";
|
|
274
|
+
stream.on("data", (d) => { out += d.toString("utf8"); });
|
|
275
|
+
if (stream.stderr) stream.stderr.on("data", (d) => { errText += d.toString("utf8"); });
|
|
276
|
+
stream.on("close", (code) => {
|
|
277
|
+
resolve({ code: code == null ? 0 : code, stdout: out, stderr: errText });
|
|
278
|
+
});
|
|
279
|
+
stream.on("error", reject);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
243
284
|
checkErr() {
|
|
244
285
|
for (const p of ERR_PATTERNS) {
|
|
245
286
|
if (p.test(this.err)) {
|
package/src/transfer.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
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
|
+
// koko 资产树路径归一化(显式 sftp_root):去首尾 /,禁止空段/./.. 与控制字符;空值 → 交给自动探测
|
|
171
|
+
function normalizeSftpRoot(raw) {
|
|
172
|
+
if (typeof raw !== "string") return null;
|
|
173
|
+
const trimmed = raw.trim().replace(/^\/+|\/+$/g, "");
|
|
174
|
+
if (!trimmed) return null;
|
|
175
|
+
for (const seg of trimmed.split("/")) {
|
|
176
|
+
if (!seg || seg === "." || seg === "..") {
|
|
177
|
+
throw tErr(`配置错误: target_hosts.sftp_root 含非法路径段 "${seg}"(空段 / "." / ".." 不允许)[${raw}]`);
|
|
178
|
+
}
|
|
179
|
+
if (/[\x00-\x1f]/.test(seg)) {
|
|
180
|
+
throw tErr(`配置错误: target_hosts.sftp_root 含控制字符 [${raw}]`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return trimmed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 传输意图校验:开关/白名单/本地文件/远程路径/flag 互斥。
|
|
188
|
+
* 任何失败抛"拒绝"类错误(调用方按退出码 2 处理并审计)。
|
|
189
|
+
* 返回 {tool, flags, tc, mode: "direct"|"koko", sftpRoot, local, remote, total}。
|
|
190
|
+
*/
|
|
191
|
+
function prepareTransfer(conf, host, tool, args = {}) {
|
|
192
|
+
const flags = resolveTransferFlags(args);
|
|
193
|
+
const tc = transferConf(conf);
|
|
194
|
+
// 连接模式判定:direct → 通道即目标机真实文件系统;菜单资产 → koko 资产树虚拟根
|
|
195
|
+
const entry = (conf.target_hosts || {})[host] || {};
|
|
196
|
+
const profileEntry = (conf.auth_profiles || {})[entry.profile] || {};
|
|
197
|
+
const isDirect = profileEntry.direct === true;
|
|
198
|
+
if (tool === "upload") {
|
|
199
|
+
if (!tc.upload.enabled) {
|
|
200
|
+
throw tErr(
|
|
201
|
+
'拒绝: 文件上传默认禁用(安全默认)。请在本机配置文件中增加 ' +
|
|
202
|
+
'"file_transfer": {"upload": {"enabled": true, "allowed_path_prefixes": ["/目录/"]}} 后重试'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!args.local) throw tErr("拒绝: 缺少本地文件路径(第一个参数)");
|
|
206
|
+
const local = path.resolve(args.local);
|
|
207
|
+
let st;
|
|
208
|
+
try { st = fs.statSync(local); } catch (_) {
|
|
209
|
+
throw tErr(`拒绝: 本地文件不存在 [${local}]`);
|
|
210
|
+
}
|
|
211
|
+
if (!st.isFile()) throw tErr(`拒绝: 本地路径不是文件 [${local}]`);
|
|
212
|
+
const remote = validateRemotePath(args.remote, "upload", conf);
|
|
213
|
+
if (!isDirect && tc.verify === "sha256") {
|
|
214
|
+
throw tErr('拒绝: 菜单资产(koko 模式)暂不支持 verify:"sha256"(远端 sha256sum 需直达 exec 通道);请将 file_transfer.verify 设为 "size"');
|
|
215
|
+
}
|
|
216
|
+
return { tool, host, flags, tc, mode: isDirect ? "direct" : "koko", sftpRoot: isDirect ? null : normalizeSftpRoot(entry.sftp_root), local, remote, total: st.size };
|
|
217
|
+
}
|
|
218
|
+
if (tool === "download") {
|
|
219
|
+
if (!args.remote) throw tErr("拒绝: 缺少远程文件路径(第一个参数)");
|
|
220
|
+
const remote = validateRemotePath(args.remote, "download", conf);
|
|
221
|
+
if (!args.local) throw tErr("拒绝: 缺少本地保存路径(第二个参数)");
|
|
222
|
+
const local = path.resolve(args.local);
|
|
223
|
+
// 本地正式目标已存在属"拒绝"级冲突(无需连接即可判定);--resume 只接半成品,不视为冲突
|
|
224
|
+
let formal = null;
|
|
225
|
+
try { formal = fs.statSync(local); } catch (_) {}
|
|
226
|
+
if (formal && !flags.resume && !flags.overwrite) {
|
|
227
|
+
throw tErr(`拒绝: 本地目标已存在 [${local}]。加 --overwrite 重传,或 --resume 续传半成品`);
|
|
228
|
+
}
|
|
229
|
+
// 本地半成品已存在且未给 --resume/--overwrite → 同为拒绝级(引导用户明确续传或重传意图)
|
|
230
|
+
let partL = null;
|
|
231
|
+
try { partL = fs.statSync(partPath(local)); } catch (_) {}
|
|
232
|
+
if (partL && !flags.resume && !flags.overwrite) {
|
|
233
|
+
throw tErr(`拒绝: 检测到未完成的传输 ${partPath(local)}。加 --resume 续传,或 --overwrite 删除后重传`);
|
|
234
|
+
}
|
|
235
|
+
if (!isDirect && tc.verify === "sha256") {
|
|
236
|
+
throw tErr('拒绝: 菜单资产(koko 模式)暂不支持 verify:"sha256"(远端 sha256sum 需直达 exec 通道);请将 file_transfer.verify 设为 "size"');
|
|
237
|
+
}
|
|
238
|
+
return { tool, host, flags, tc, mode: isDirect ? "direct" : "koko", sftpRoot: isDirect ? null : normalizeSftpRoot(entry.sftp_root), remote, local };
|
|
239
|
+
}
|
|
240
|
+
throw tErr(`未知传输方向 [${tool}]`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ==== koko 资产树定位(菜单资产模式专用)====
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 在 koko 资产树虚拟根中定位目标资产的目录路径(自动探测,DFS 深度 ≤3 层)。
|
|
247
|
+
* 返回 "/Default/资产组/资产IP" 形态;命中多个 → 报错要求显式 sftp_root;未命中 → 报错引导配置。
|
|
248
|
+
*/
|
|
249
|
+
function resolveKokoBase(sftp, host, explicitRoot) {
|
|
250
|
+
if (explicitRoot) return Promise.resolve(`/${explicitRoot}`);
|
|
251
|
+
const hits = [];
|
|
252
|
+
const walk = (dir, remainingDepth) => new Promise((resolveWalk) => {
|
|
253
|
+
sftp.readdir(dir, async (err, list) => {
|
|
254
|
+
if (err) return resolveWalk(); // 不可读子树静默跳过(收藏夹等)
|
|
255
|
+
const tasks = [];
|
|
256
|
+
for (const x of list) {
|
|
257
|
+
if (!x.longname.startsWith("d")) continue;
|
|
258
|
+
const full = `${dir.replace(/\/+$/, "")}/${x.filename}`;
|
|
259
|
+
if (x.filename === host) { hits.push(full); continue; }
|
|
260
|
+
if (remainingDepth > 0) tasks.push(walk(full, remainingDepth - 1));
|
|
261
|
+
}
|
|
262
|
+
if (tasks.length) await Promise.all(tasks);
|
|
263
|
+
resolveWalk();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
return walk("/", 2).then(() => {
|
|
267
|
+
if (hits.length > 1) {
|
|
268
|
+
throw tErr(`拒绝: 资产 [${host}] 在 koko 资产树命中多个目录(${hits.join("、")});请在 target_hosts[${host}] 增加 "sftp_root" 显式声明资产目录`);
|
|
269
|
+
}
|
|
270
|
+
if (hits.length === 0) {
|
|
271
|
+
throw tErr(`拒绝: 自动探测未在 koko 资产树中找到资产目录 [${host}](已扫描根下两层)。请在 target_hosts[${host}] 增加 "sftp_root": "资产模块/资产组/${host}" 显式声明`);
|
|
272
|
+
}
|
|
273
|
+
return hits[0];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ==== 上传 ====
|
|
278
|
+
|
|
279
|
+
async function uploadFile(shell, conf, plan, onProgress) {
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
const { flags, tc } = plan;
|
|
282
|
+
const local = plan.local;
|
|
283
|
+
const remote = plan.remote;
|
|
284
|
+
const part = partPath(remote);
|
|
285
|
+
const st = fs.statSync(local);
|
|
286
|
+
const total = st.size;
|
|
287
|
+
|
|
288
|
+
const sftp = await shell.openSftp();
|
|
289
|
+
// direct 通道 → base=""(语义路径即真实路径);koko 通道 → base=资产树目录(语义路径映射进去)
|
|
290
|
+
const base = plan.mode === "direct" ? "" : await resolveKokoBase(sftp, plan.host, plan.sftpRoot);
|
|
291
|
+
const P = (semanticAbs) => base ? base + semanticAbs : semanticAbs;
|
|
292
|
+
const rRemote = P(remote);
|
|
293
|
+
const rPart = P(part);
|
|
294
|
+
|
|
295
|
+
// 冲突裁决:正式目标 / 半成品 / flags
|
|
296
|
+
const remoteInfo = await sftpStat(sftp, rRemote, "远程目标");
|
|
297
|
+
if (remoteInfo) {
|
|
298
|
+
if (flags.resume) {
|
|
299
|
+
throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;远程正式目标已存在 [${remote}](覆盖请用 --overwrite)`);
|
|
300
|
+
}
|
|
301
|
+
if (!flags.overwrite) {
|
|
302
|
+
throw tErr(`拒绝: 远程目标已存在 [${remote}]。加 --overwrite 重传,或 --resume 续传半成品`);
|
|
303
|
+
}
|
|
304
|
+
await sftpUnlink(sftp, rRemote, "远程正式目标");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const partInfo = await sftpStat(sftp, rPart, "半成品");
|
|
308
|
+
let offset = 0;
|
|
309
|
+
let resumed = false;
|
|
310
|
+
if (partInfo) {
|
|
311
|
+
if (!flags.overwrite && !flags.resume) {
|
|
312
|
+
throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
|
|
313
|
+
}
|
|
314
|
+
if (flags.overwrite) {
|
|
315
|
+
await sftpUnlink(sftp, rPart, "半成品");
|
|
316
|
+
} else {
|
|
317
|
+
const r = computeResumeOffset(partInfo.size, total);
|
|
318
|
+
if (r.mode === "ready") {
|
|
319
|
+
// 半成品与源大小一致:可选 sha 校验后直接改名(免重传)
|
|
320
|
+
if (tc.verify === "sha256") {
|
|
321
|
+
const localSha = await sha256File(local);
|
|
322
|
+
const remoteSha = await remoteSha256(shell, conf, part);
|
|
323
|
+
if (remoteSha !== localSha) {
|
|
324
|
+
await sftpUnlink(sftp, rPart, "不一致的半成品");
|
|
325
|
+
throw tErr(`校验失败: 半成品 sha256 与本地源不一致,已删除 ${part}(请用 --overwrite 重传)`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await sftpRename(sftp, rPart, rRemote);
|
|
329
|
+
return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
330
|
+
}
|
|
331
|
+
if (r.mode === "restart") {
|
|
332
|
+
await sftpUnlink(sftp, rPart, "与源大小不符的半成品");
|
|
333
|
+
} else {
|
|
334
|
+
offset = r.offset;
|
|
335
|
+
resumed = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let localSha = null;
|
|
341
|
+
if (tc.verify === "sha256") {
|
|
342
|
+
localSha = await sha256File(local);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 推送:offset===0 走 fastPut(并行分块,吞吐比 stream 高数倍),续传用 stream 管道定位追加
|
|
346
|
+
await (async () => {
|
|
347
|
+
if (offset === 0 && total > 0) {
|
|
348
|
+
try {
|
|
349
|
+
await sftpFastPut(sftp, local, rPart, onProgress);
|
|
350
|
+
return;
|
|
351
|
+
} catch (e) {
|
|
352
|
+
// fastPut 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const rs = fs.createReadStream(local, { start: offset });
|
|
356
|
+
const ws = sftp.createWriteStream(rPart, offset > 0 ? { flags: "r+", start: offset } : { flags: "w" });
|
|
357
|
+
await pump(rs, ws, {
|
|
358
|
+
sentBytes: () => offset + rs.bytesRead,
|
|
359
|
+
onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
|
|
360
|
+
watchdogMsg: "上传",
|
|
361
|
+
});
|
|
362
|
+
})();
|
|
363
|
+
|
|
364
|
+
// 完整性校验(对 part 散列,改名字节即与校验字节一致)
|
|
365
|
+
if (tc.verify === "sha256") {
|
|
366
|
+
const remoteSha = await remoteSha256(shell, conf, part);
|
|
367
|
+
if (remoteSha !== localSha) {
|
|
368
|
+
await sftpUnlink(sftp, rPart, "校验失败的半成品");
|
|
369
|
+
throw tErr(`校验失败: 上传内容 sha256 与本地源不一致(网络或源文件变动),已删除半成品`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
await sftpRename(sftp, rPart, rRemote);
|
|
374
|
+
return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ==== 下载 ====
|
|
378
|
+
|
|
379
|
+
async function downloadFile(shell, conf, plan, onProgress) {
|
|
380
|
+
const startedAt = Date.now();
|
|
381
|
+
const { flags, tc } = plan;
|
|
382
|
+
const remote = plan.remote;
|
|
383
|
+
const local = plan.local;
|
|
384
|
+
const part = partPath(local);
|
|
385
|
+
|
|
386
|
+
const dir = path.dirname(local);
|
|
387
|
+
if (!fs.existsSync(dir)) throw tErr(`拒绝: 本地目标目录不存在 [${dir}]`);
|
|
388
|
+
|
|
389
|
+
const sftp = await shell.openSftp();
|
|
390
|
+
const base = plan.mode === "direct" ? "" : await resolveKokoBase(sftp, plan.host, plan.sftpRoot);
|
|
391
|
+
const P = (semanticAbs) => base ? base + semanticAbs : semanticAbs;
|
|
392
|
+
const rRemote = P(remote);
|
|
393
|
+
|
|
394
|
+
const remoteInfo = await sftpStat(sftp, rRemote, "远程文件");
|
|
395
|
+
if (!remoteInfo) throw tErr(`拒绝: 远程文件不存在或不可读 [${remote}]`);
|
|
396
|
+
if (remoteInfo.isDirectory()) throw tErr(`拒绝: 远程目标是目录,传输仅支持单文件 [${remote}]`);
|
|
397
|
+
const total = Number(remoteInfo.size || 0);
|
|
398
|
+
|
|
399
|
+
// 本地正式目标冲突裁决
|
|
400
|
+
let localFormal = null;
|
|
401
|
+
try { localFormal = fs.statSync(local); } catch (_) {}
|
|
402
|
+
if (localFormal) {
|
|
403
|
+
if (flags.resume) {
|
|
404
|
+
throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;本地正式目标已存在 [${local}](覆盖请用 --overwrite)`);
|
|
405
|
+
}
|
|
406
|
+
if (!flags.overwrite) {
|
|
407
|
+
throw tErr(`拒绝: 本地目标已存在 [${local}]。加 --overwrite 重传,或 --resume 续传半成品`);
|
|
408
|
+
}
|
|
409
|
+
fs.unlinkSync(local);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 本地半成品裁决
|
|
413
|
+
let partSt = null;
|
|
414
|
+
try { partSt = fs.statSync(part); } catch (_) {}
|
|
415
|
+
let offset = 0;
|
|
416
|
+
let resumed = false;
|
|
417
|
+
if (partSt) {
|
|
418
|
+
if (!flags.overwrite && !flags.resume) {
|
|
419
|
+
throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
|
|
420
|
+
}
|
|
421
|
+
if (flags.overwrite) {
|
|
422
|
+
fs.unlinkSync(part);
|
|
423
|
+
} else {
|
|
424
|
+
const r = computeResumeOffset(partSt.size, total);
|
|
425
|
+
if (r.mode === "ready") {
|
|
426
|
+
if (tc.verify === "sha256") {
|
|
427
|
+
const remoteSha = await remoteSha256(shell, conf, remote);
|
|
428
|
+
const localSha = await sha256File(part);
|
|
429
|
+
if (remoteSha !== localSha) {
|
|
430
|
+
fs.unlinkSync(part);
|
|
431
|
+
throw tErr(`校验失败: 半成品 sha256 与远端文件不一致,已删除 ${part}(请用 --overwrite 重传)`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
fs.renameSync(part, local);
|
|
435
|
+
return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
436
|
+
}
|
|
437
|
+
if (r.mode === "restart") {
|
|
438
|
+
fs.unlinkSync(part);
|
|
439
|
+
} else {
|
|
440
|
+
offset = r.offset;
|
|
441
|
+
resumed = true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 远端整体散列(可选):与下载内容比对,可捕获"传输期间远端文件被改"
|
|
447
|
+
let remoteSha = null;
|
|
448
|
+
if (tc.verify === "sha256") remoteSha = await remoteSha256(shell, conf, remote);
|
|
449
|
+
|
|
450
|
+
// 拉取:offset===0 走 fastGet(并行分块,吞吐比 stream 高数倍),续传用 stream 管道追加
|
|
451
|
+
await (async () => {
|
|
452
|
+
if (offset === 0 && total > 0) {
|
|
453
|
+
try {
|
|
454
|
+
await sftpFastGet(sftp, rRemote, part, onProgress);
|
|
455
|
+
return;
|
|
456
|
+
} catch (e) {
|
|
457
|
+
// fastGet 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const rs = sftp.createReadStream(rRemote, offset > 0 ? { start: offset } : { flags: "r" });
|
|
461
|
+
const ws = fs.createWriteStream(part, resumed ? { flags: "a" } : { flags: "w" });
|
|
462
|
+
await pump(rs, ws, {
|
|
463
|
+
sentBytes: () => offset + ws.bytesWritten,
|
|
464
|
+
onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
|
|
465
|
+
watchdogMsg: "下载",
|
|
466
|
+
});
|
|
467
|
+
})();
|
|
468
|
+
|
|
469
|
+
// 完整性校验
|
|
470
|
+
if (tc.verify === "sha256") {
|
|
471
|
+
const localSha = await sha256File(part);
|
|
472
|
+
if (localSha !== remoteSha) {
|
|
473
|
+
fs.unlinkSync(part);
|
|
474
|
+
throw tErr(`校验失败: 下载内容 sha256 与远端文件不一致(传输期间文件可能被改),已删除半成品`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fs.renameSync(part, local);
|
|
479
|
+
return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = {
|
|
483
|
+
prepareTransfer,
|
|
484
|
+
uploadFile,
|
|
485
|
+
downloadFile,
|
|
486
|
+
// 仅供测试与进度展示
|
|
487
|
+
pump,
|
|
488
|
+
remoteSha256,
|
|
489
|
+
sha256File,
|
|
490
|
+
normalizeSftpRoot,
|
|
491
|
+
resolveKokoBase,
|
|
492
|
+
};
|
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
|
};
|