@zicolasjac-ai/remote-readonly-ssh 1.4.1 → 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 +9 -5
- package/bin/rrs.js +4 -9
- package/config.example.json +2 -1
- package/package.json +1 -1
- package/skills/remote-readonly-ssh/SKILL.md +3 -3
- package/src/config.js +7 -0
- package/src/jumpshell.js +6 -10
- package/src/transfer.js +89 -19
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ 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
|
|
71
|
+
| `download <远程> <本地>` | 下载单文件(SFTP:direct 直连或堡垒机 koko 网关;shadow/.ssh 类路径防呆) |
|
|
72
72
|
| `upload <本地> <远程>` | 上传单文件(写操作,**默认禁用**:须 `file_transfer.upload.enabled=true` + 独立写路径白名单) |
|
|
73
73
|
|
|
74
74
|
## 配置
|
|
@@ -78,7 +78,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
78
78
|
| 键 | 说明 |
|
|
79
79
|
|-----|------|
|
|
80
80
|
| `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth, direct?}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`direct: true` 表示**直连裸机**(跳过堡垒机菜单,适合无堡垒机的服务器;命令只读校验与堡垒机模式完全一致)。`list-tools` 只显示模式与主机,不显示任何凭据 |
|
|
81
|
-
| `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)** |
|
|
82
82
|
| `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
|
|
83
83
|
| `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
|
|
84
84
|
| `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
|
|
@@ -99,10 +99,14 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
99
99
|
|
|
100
100
|
**硬约束**:
|
|
101
101
|
|
|
102
|
-
-
|
|
103
|
-
-
|
|
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 级为常规场景;速度受链路带宽约束)。
|
|
104
107
|
- 目标(两端)**已存在即拒绝**:`--overwrite` 从头重传;`--resume` 断点续传未完成半成品 `<目标>.rrs-part`(互斥,续传前校验两端字节数一致)。产物先写半成品、校验后原子改名,**见到正式文件即视为完整**。
|
|
105
|
-
-
|
|
108
|
+
- 单文件传输;不支持目录递归、不支持 `docker exec` 容器内文件。
|
|
109
|
+
- 上传需 opt-in + 独立写路径白名单(语义路径层校验,koko 模式同样生效);上传/下载不受 `(语义路径) 之外的资产树越界`——映射后被强制限定在该资产目录内。
|
|
106
110
|
|
|
107
111
|
**下载**(默认可用):
|
|
108
112
|
|
package/bin/rrs.js
CHANGED
|
@@ -11,7 +11,6 @@ const {
|
|
|
11
11
|
const setupSkill = require("../src/setup");
|
|
12
12
|
const initWizard = require("../src/init");
|
|
13
13
|
const transfer = require("../src/transfer");
|
|
14
|
-
const { resolveProfile } = require("../src/jumpshell");
|
|
15
14
|
|
|
16
15
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
17
16
|
|
|
@@ -259,11 +258,6 @@ async function runTransfer(opts, conf) {
|
|
|
259
258
|
? `upload ${args.local || "(无)"} -> ${args.remote || "(无)"}`
|
|
260
259
|
: `download ${args.remote || "(无)"} -> ${args.local || "(无)"}`];
|
|
261
260
|
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
261
|
} catch (e) {
|
|
268
262
|
audit(conf, tool, host || "", cmds, false, e.message);
|
|
269
263
|
emitResult(opts, { ok: false, tool, host: host || "", error: e.message, kind: "拒绝" });
|
|
@@ -283,16 +277,17 @@ async function runTransfer(opts, conf) {
|
|
|
283
277
|
|
|
284
278
|
const speedBps = result.bytes / Math.max(0.001, result.elapsedMs / 1000);
|
|
285
279
|
const resumeTag = result.resumed ? "(断点续传)" : "";
|
|
280
|
+
const viaTag = plan.mode === "koko" ? "(koko 资产树直传)" : "";
|
|
286
281
|
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}`;
|
|
282
|
+
const text = `${isUpload ? "上传" : "下载"}完成: ${plan.local} ↔ ${plan.remote} | ${fmtBytes(result.bytes)} 耗时 ${fmtDur(result.elapsedMs)} 均 ${(speedBps / 1048576).toFixed(1)} MiB/s${resumeTag}${viaTag}${verifyTag}`;
|
|
288
283
|
audit(conf, tool, host, cmds, true,
|
|
289
|
-
`bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite}`);
|
|
284
|
+
`bytes=${result.bytes} elapsed=${result.elapsedMs}ms resumed=${result.resumed} verify=${result.verify} overwrite=${plan.flags.overwrite} via=${plan.mode}`);
|
|
290
285
|
emitResult(opts, {
|
|
291
286
|
ok: true, tool, host,
|
|
292
287
|
local: plan.local, remote: plan.remote,
|
|
293
288
|
bytes: result.bytes, resumed: result.resumed,
|
|
294
289
|
elapsed_ms: result.elapsedMs, verify: result.verify,
|
|
295
|
-
overwrite: plan.flags.overwrite,
|
|
290
|
+
overwrite: plan.flags.overwrite, via: plan.mode,
|
|
296
291
|
output: text,
|
|
297
292
|
});
|
|
298
293
|
} catch (e) {
|
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,
|
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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: remote-readonly-ssh
|
|
3
|
-
description: 通过 JumpServer 堡垒机对服务器做白名单强制的运维查询与单文件传输(sys-info / proc / ports / dir / read-file / bin-version / exec-only / download / upload)。命令默认只读、前缀白名单 + 禁止模式 + 路径白名单 +
|
|
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:
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
|
|
12
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
15
|
|
|
16
16
|
安全定位:**命令查询技术上零写能力**;文件下载原则上不写远端;**文件上传是唯一写远程状态的能力**——默认禁用,须在本机配置显式开启 + 写路径白名单。
|
|
17
17
|
|
|
@@ -114,7 +114,7 @@ rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--h
|
|
|
114
114
|
5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
|
|
115
115
|
6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
|
|
116
116
|
7. **传输约束(upload/download)**:
|
|
117
|
-
-
|
|
117
|
+
- 通道:`direct: true` 直连资产 = 真实文件系统;普通菜单资产 = **koko SFTP 网关**(连接后自动探测 koko 资产树定位该资产目录并映射语义路径;命名特殊时在 `target_hosts` 加 `"sftp_root": "模块/资产组/IP"` 显式声明;koko 模式不支持 `verify:"sha256"`,远端散列不可用)
|
|
118
118
|
- `upload` 是**写操作**:默认禁用;只有用户明确要求传文件且配置已启用(`file_transfer.upload.enabled: true` + 写路径白名单)时才可用;启用与否、白名单变更属配置修改,须先征得用户同意(同规则 6)
|
|
119
119
|
- `download` 无读白名单,但敏感路径防呆生效:内置(`/etc/shadow` 族、任意 `/.ssh/` 目录)+ `blocked_paths` 全局黑名单 + `file_transfer.download.blocked_paths` 追加;被拒即是禁止信号,**不要**换路径表达方式重试(可把需求转述给用户人工决策)
|
|
120
120
|
- `upload` 在写白名单之上同样受防呆约束(`.ssh` / shadow / `blocked_paths` 族不可覆盖)
|
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
|
|
package/src/jumpshell.js
CHANGED
|
@@ -241,22 +241,18 @@ class JumpShell {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
|
-
* 打开 SFTP
|
|
245
|
-
*
|
|
246
|
-
*
|
|
244
|
+
* 打开 SFTP 子系统通道(文件传输专用)。两种模式:
|
|
245
|
+
* - direct: true 直连认证组 → 通道即目标机真实文件系统
|
|
246
|
+
* - 堡垒机菜单认证组 → JumpServer koko 的 SFTP 文件传输入口(资产树虚拟根),
|
|
247
|
+
* 无需菜单导航;路径映射与资产定位由 transfer 层完成。
|
|
248
|
+
* 失败通常意味着堡垒机未开放文件传输或资产未授权 SFTP,报错给出可操作指引。
|
|
247
249
|
*/
|
|
248
250
|
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
251
|
if (!this.conn) throw new Error("SSH 未连接");
|
|
256
252
|
return new Promise((resolve, reject) => {
|
|
257
253
|
this.conn.sftp((err, sftp) => {
|
|
258
254
|
if (err) {
|
|
259
|
-
reject(new Error(`SFTP 子系统打开失败: ${err.message}
|
|
255
|
+
reject(new Error(`SFTP 子系统打开失败: ${err.message}(堡垒机需开放文件传输(koko)或走 direct 直连认证组;口令账号需有读写权限)`));
|
|
260
256
|
} else {
|
|
261
257
|
resolve(sftp);
|
|
262
258
|
}
|
package/src/transfer.js
CHANGED
|
@@ -167,14 +167,34 @@ function remoteSha256(shell, conf, p) {
|
|
|
167
167
|
|
|
168
168
|
// ==== 前置校验(不经网络)====
|
|
169
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
|
+
|
|
170
186
|
/**
|
|
171
187
|
* 传输意图校验:开关/白名单/本地文件/远程路径/flag 互斥。
|
|
172
188
|
* 任何失败抛"拒绝"类错误(调用方按退出码 2 处理并审计)。
|
|
173
|
-
* 返回 {tool, flags, tc, local, remote, total}。
|
|
189
|
+
* 返回 {tool, flags, tc, mode: "direct"|"koko", sftpRoot, local, remote, total}。
|
|
174
190
|
*/
|
|
175
191
|
function prepareTransfer(conf, host, tool, args = {}) {
|
|
176
192
|
const flags = resolveTransferFlags(args);
|
|
177
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;
|
|
178
198
|
if (tool === "upload") {
|
|
179
199
|
if (!tc.upload.enabled) {
|
|
180
200
|
throw tErr(
|
|
@@ -190,7 +210,10 @@ function prepareTransfer(conf, host, tool, args = {}) {
|
|
|
190
210
|
}
|
|
191
211
|
if (!st.isFile()) throw tErr(`拒绝: 本地路径不是文件 [${local}]`);
|
|
192
212
|
const remote = validateRemotePath(args.remote, "upload", conf);
|
|
193
|
-
|
|
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 };
|
|
194
217
|
}
|
|
195
218
|
if (tool === "download") {
|
|
196
219
|
if (!args.remote) throw tErr("拒绝: 缺少远程文件路径(第一个参数)");
|
|
@@ -209,11 +232,48 @@ function prepareTransfer(conf, host, tool, args = {}) {
|
|
|
209
232
|
if (partL && !flags.resume && !flags.overwrite) {
|
|
210
233
|
throw tErr(`拒绝: 检测到未完成的传输 ${partPath(local)}。加 --resume 续传,或 --overwrite 删除后重传`);
|
|
211
234
|
}
|
|
212
|
-
|
|
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 };
|
|
213
239
|
}
|
|
214
240
|
throw tErr(`未知传输方向 [${tool}]`);
|
|
215
241
|
}
|
|
216
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
|
+
|
|
217
277
|
// ==== 上传 ====
|
|
218
278
|
|
|
219
279
|
async function uploadFile(shell, conf, plan, onProgress) {
|
|
@@ -226,9 +286,14 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
226
286
|
const total = st.size;
|
|
227
287
|
|
|
228
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);
|
|
229
294
|
|
|
230
295
|
// 冲突裁决:正式目标 / 半成品 / flags
|
|
231
|
-
const remoteInfo = await sftpStat(sftp,
|
|
296
|
+
const remoteInfo = await sftpStat(sftp, rRemote, "远程目标");
|
|
232
297
|
if (remoteInfo) {
|
|
233
298
|
if (flags.resume) {
|
|
234
299
|
throw tErr(`拒绝: --resume 只用于续传 ${part} 半成品;远程正式目标已存在 [${remote}](覆盖请用 --overwrite)`);
|
|
@@ -236,10 +301,10 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
236
301
|
if (!flags.overwrite) {
|
|
237
302
|
throw tErr(`拒绝: 远程目标已存在 [${remote}]。加 --overwrite 重传,或 --resume 续传半成品`);
|
|
238
303
|
}
|
|
239
|
-
await sftpUnlink(sftp,
|
|
304
|
+
await sftpUnlink(sftp, rRemote, "远程正式目标");
|
|
240
305
|
}
|
|
241
306
|
|
|
242
|
-
const partInfo = await sftpStat(sftp,
|
|
307
|
+
const partInfo = await sftpStat(sftp, rPart, "半成品");
|
|
243
308
|
let offset = 0;
|
|
244
309
|
let resumed = false;
|
|
245
310
|
if (partInfo) {
|
|
@@ -247,7 +312,7 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
247
312
|
throw tErr(`拒绝: 检测到未完成的传输 ${part}。加 --resume 续传,或 --overwrite 删除后重传`);
|
|
248
313
|
}
|
|
249
314
|
if (flags.overwrite) {
|
|
250
|
-
await sftpUnlink(sftp,
|
|
315
|
+
await sftpUnlink(sftp, rPart, "半成品");
|
|
251
316
|
} else {
|
|
252
317
|
const r = computeResumeOffset(partInfo.size, total);
|
|
253
318
|
if (r.mode === "ready") {
|
|
@@ -256,15 +321,15 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
256
321
|
const localSha = await sha256File(local);
|
|
257
322
|
const remoteSha = await remoteSha256(shell, conf, part);
|
|
258
323
|
if (remoteSha !== localSha) {
|
|
259
|
-
await sftpUnlink(sftp,
|
|
324
|
+
await sftpUnlink(sftp, rPart, "不一致的半成品");
|
|
260
325
|
throw tErr(`校验失败: 半成品 sha256 与本地源不一致,已删除 ${part}(请用 --overwrite 重传)`);
|
|
261
326
|
}
|
|
262
327
|
}
|
|
263
|
-
await sftpRename(sftp,
|
|
328
|
+
await sftpRename(sftp, rPart, rRemote);
|
|
264
329
|
return { bytes: total, resumed: false, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
265
330
|
}
|
|
266
331
|
if (r.mode === "restart") {
|
|
267
|
-
await sftpUnlink(sftp,
|
|
332
|
+
await sftpUnlink(sftp, rPart, "与源大小不符的半成品");
|
|
268
333
|
} else {
|
|
269
334
|
offset = r.offset;
|
|
270
335
|
resumed = true;
|
|
@@ -281,14 +346,14 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
281
346
|
await (async () => {
|
|
282
347
|
if (offset === 0 && total > 0) {
|
|
283
348
|
try {
|
|
284
|
-
await sftpFastPut(sftp, local,
|
|
349
|
+
await sftpFastPut(sftp, local, rPart, onProgress);
|
|
285
350
|
return;
|
|
286
351
|
} catch (e) {
|
|
287
352
|
// fastPut 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
|
|
288
353
|
}
|
|
289
354
|
}
|
|
290
355
|
const rs = fs.createReadStream(local, { start: offset });
|
|
291
|
-
const ws = sftp.createWriteStream(
|
|
356
|
+
const ws = sftp.createWriteStream(rPart, offset > 0 ? { flags: "r+", start: offset } : { flags: "w" });
|
|
292
357
|
await pump(rs, ws, {
|
|
293
358
|
sentBytes: () => offset + rs.bytesRead,
|
|
294
359
|
onProgress: onProgress ? (sent) => onProgress(sent, total) : null,
|
|
@@ -300,13 +365,13 @@ async function uploadFile(shell, conf, plan, onProgress) {
|
|
|
300
365
|
if (tc.verify === "sha256") {
|
|
301
366
|
const remoteSha = await remoteSha256(shell, conf, part);
|
|
302
367
|
if (remoteSha !== localSha) {
|
|
303
|
-
await sftpUnlink(sftp,
|
|
368
|
+
await sftpUnlink(sftp, rPart, "校验失败的半成品");
|
|
304
369
|
throw tErr(`校验失败: 上传内容 sha256 与本地源不一致(网络或源文件变动),已删除半成品`);
|
|
305
370
|
}
|
|
306
371
|
}
|
|
307
372
|
|
|
308
|
-
await sftpRename(sftp,
|
|
309
|
-
return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
373
|
+
await sftpRename(sftp, rPart, rRemote);
|
|
374
|
+
return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
310
375
|
}
|
|
311
376
|
|
|
312
377
|
// ==== 下载 ====
|
|
@@ -322,8 +387,11 @@ async function downloadFile(shell, conf, plan, onProgress) {
|
|
|
322
387
|
if (!fs.existsSync(dir)) throw tErr(`拒绝: 本地目标目录不存在 [${dir}]`);
|
|
323
388
|
|
|
324
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);
|
|
325
393
|
|
|
326
|
-
const remoteInfo = await sftpStat(sftp,
|
|
394
|
+
const remoteInfo = await sftpStat(sftp, rRemote, "远程文件");
|
|
327
395
|
if (!remoteInfo) throw tErr(`拒绝: 远程文件不存在或不可读 [${remote}]`);
|
|
328
396
|
if (remoteInfo.isDirectory()) throw tErr(`拒绝: 远程目标是目录,传输仅支持单文件 [${remote}]`);
|
|
329
397
|
const total = Number(remoteInfo.size || 0);
|
|
@@ -383,13 +451,13 @@ async function downloadFile(shell, conf, plan, onProgress) {
|
|
|
383
451
|
await (async () => {
|
|
384
452
|
if (offset === 0 && total > 0) {
|
|
385
453
|
try {
|
|
386
|
-
await sftpFastGet(sftp,
|
|
454
|
+
await sftpFastGet(sftp, rRemote, part, onProgress);
|
|
387
455
|
return;
|
|
388
456
|
} catch (e) {
|
|
389
457
|
// fastGet 失败(如非 OpenSSH sftp-server):回退 stream 管道,保可用性
|
|
390
458
|
}
|
|
391
459
|
}
|
|
392
|
-
const rs = sftp.createReadStream(
|
|
460
|
+
const rs = sftp.createReadStream(rRemote, offset > 0 ? { start: offset } : { flags: "r" });
|
|
393
461
|
const ws = fs.createWriteStream(part, resumed ? { flags: "a" } : { flags: "w" });
|
|
394
462
|
await pump(rs, ws, {
|
|
395
463
|
sentBytes: () => offset + ws.bytesWritten,
|
|
@@ -408,7 +476,7 @@ async function downloadFile(shell, conf, plan, onProgress) {
|
|
|
408
476
|
}
|
|
409
477
|
|
|
410
478
|
fs.renameSync(part, local);
|
|
411
|
-
return { bytes: total, resumed, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
479
|
+
return { bytes: total, resumed, via: plan.mode, elapsedMs: Date.now() - startedAt, verify: tc.verify };
|
|
412
480
|
}
|
|
413
481
|
|
|
414
482
|
module.exports = {
|
|
@@ -419,4 +487,6 @@ module.exports = {
|
|
|
419
487
|
pump,
|
|
420
488
|
remoteSha256,
|
|
421
489
|
sha256File,
|
|
490
|
+
normalizeSftpRoot,
|
|
491
|
+
resolveKokoBase,
|
|
422
492
|
};
|