@zicolasjac-ai/remote-readonly-ssh 1.4.1 → 1.4.3
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 +15 -12
- package/bin/postinstall.js +24 -3
- package/bin/rrs.js +4 -9
- package/config.example.json +2 -1
- package/package.json +1 -1
- package/skills/remote-readonly-ssh/SKILL.md +6 -7
- package/src/config.js +7 -0
- package/src/jumpshell.js +6 -10
- package/src/transfer.js +89 -19
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ npx 会自动拉取最新版本、进入初始化向导,依次询问:
|
|
|
25
25
|
1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
|
|
26
26
|
2. **认证组**:堡垒机主机 / 端口 / 用户名 / 认证模式(SSH 密钥或密码)/ 凭据;可配置多个认证组
|
|
27
27
|
3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`),并为每台主机指定归属的认证组(多台主机可共用同一组)
|
|
28
|
-
4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI
|
|
28
|
+
4. 是否把 SKILL.md 安装到 opencode / Claude Code / Cursor 等 AI 助手(安装命令的 postinstall 已自动分发,此步幂等可确认)
|
|
29
29
|
|
|
30
30
|
向导结束后 `~/.rrs/config.json` 已就位、`~/.rrs/audit.log` 已建好、AI skill 已分发。直接试跑:
|
|
31
31
|
|
|
@@ -38,7 +38,7 @@ npx -y @zicolasjac-ai/remote-readonly-ssh@latest sys-info --json
|
|
|
38
38
|
每次调用都 `npx` 较慢(需下载/缓存)。如果要把 `rrs` 作为日常命令长期使用:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
41
|
+
npm install -g @zicolasjac-ai/remote-readonly-ssh # postinstall 自动分发 AI skill
|
|
42
42
|
rrs init # TTY 下仍是交互向导;非交互环境只生成配置模板
|
|
43
43
|
rrs sys-info --json # 直接用 rrs 前缀
|
|
44
44
|
```
|
|
@@ -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
|
|
|
@@ -141,7 +145,7 @@ rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
|
|
|
141
145
|
|
|
142
146
|
## Agent Skills
|
|
143
147
|
|
|
144
|
-
本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs
|
|
148
|
+
本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。**`npm install` 时 postinstall 会自动分发**(agents 实体 + opencode/claude/qoder 软链接),无需手工执行;`rrs setup skills` 保留为手工重装 / 只装部分 agent 的方式:
|
|
145
149
|
|
|
146
150
|
| `--agent` | 写入方式 | 路径 |
|
|
147
151
|
|-----------|----------|------|
|
|
@@ -151,17 +155,16 @@ rrs upload "E:\deploy\nginx.conf" /opt/upload/nginx.conf --host 10.0.0.2 --json
|
|
|
151
155
|
| `qoder` | 软链接 → agents | `~/.qoder/skills/remote-readonly-ssh/SKILL.md` |
|
|
152
156
|
| `all`(默认) | 实体 + 上述 3 个软链接 | 一行命令装齐 |
|
|
153
157
|
|
|
154
|
-
`rrs setup skills` 默认 `agent=all
|
|
158
|
+
`rrs setup skills` 默认 `agent=all`(不询问,直接装;postinstall 自动分发亦采用此默认),输出一行简明提示:
|
|
155
159
|
|
|
156
160
|
```
|
|
157
161
|
rrs skill 已安装: agents (实体), opencode/claude/qoder (软链接)
|
|
158
162
|
```
|
|
159
163
|
|
|
160
|
-
> **Windows 注意**:普通用户缺少 `SeCreateSymbolicLinkPrivilege`,软链接创建会被 OS 拒绝;rrs 会自动 fallback 为复制,并提示"Windows 已 fallback 为复制(请开启开发者模式以启用真软链接)"。开启开发者模式后重新跑 `rrs setup skills
|
|
164
|
+
> **Windows 注意**:普通用户缺少 `SeCreateSymbolicLinkPrivilege`,软链接创建会被 OS 拒绝;rrs 会自动 fallback 为复制,并提示"Windows 已 fallback 为复制(请开启开发者模式以启用真软链接)"。开启开发者模式后重新跑 `rrs setup skills`(或重装包)即可使用真软链接。
|
|
161
165
|
|
|
162
166
|
```bash
|
|
163
|
-
rrs setup skills #
|
|
164
|
-
npx -y @zicolasjac-ai/remote-readonly-ssh@latest setup skills # 全局未装时用 npx
|
|
167
|
+
rrs setup skills # 手工重装(正常情况 npm install 已自动分发)
|
|
165
168
|
rrs setup skills --agent opencode # 只装 opencode 链接(agents 实体也自动建)
|
|
166
169
|
rrs setup skills --agent qoder # 只装 qoder 链接
|
|
167
170
|
rrs setup skills --dry-run # 仅预览将要创建的目标
|
package/bin/postinstall.js
CHANGED
|
@@ -7,7 +7,28 @@ const path = require("path");
|
|
|
7
7
|
|
|
8
8
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* npm 安装完成后自动把 SKILL.md 分发到各 AI 助手的 skills 目录(agents 实体 + 软链接),
|
|
12
|
+
* 使 "npm install -g @latest" 一步完成 CLI 与 AI skill 的同步升级;失败不阻塞安装。
|
|
13
|
+
* 幂等:重复安装/升级会重建实体与软链(Windows 无符号链接权限时自动退化为复制)。
|
|
14
|
+
*/
|
|
15
|
+
function autoInstallSkills() {
|
|
16
|
+
try {
|
|
17
|
+
const results = setupSkill.install(PACKAGE_ROOT, { agent: "all" });
|
|
18
|
+
const canonical = results.find((r) => r.agent === "agents");
|
|
19
|
+
const links = results.filter((r) => r.agent !== "agents");
|
|
20
|
+
const anyFallback = links.some((r) => r.fallback);
|
|
21
|
+
const names = links.map((r) => r.agent).join("/");
|
|
22
|
+
const done = canonical && links.length ? `agents (实体), ${names} (软链接)` : canonical ? "agents (实体)" : names;
|
|
23
|
+
console.log(`rrs AI skill 已同步: ${done}${anyFallback ? " — Windows 已自动 fallback 为复制(开启开发者模式可获得真软链接)" : ""}`);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`[rrs postinstall] AI skill 自动分发失败(不影响 CLI 使用): ${e.message}`);
|
|
26
|
+
console.error("可稍后手动执行: rrs setup skills");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
(async () => {
|
|
31
|
+
autoInstallSkills();
|
|
11
32
|
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
12
33
|
if (tty) {
|
|
13
34
|
try {
|
|
@@ -19,9 +40,9 @@ const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
|
19
40
|
} else {
|
|
20
41
|
console.log("");
|
|
21
42
|
console.log("rrs 安装完成。下一步:");
|
|
22
|
-
console.log(" rrs init #
|
|
23
|
-
console.log(" rrs setup skills
|
|
43
|
+
console.log(" rrs init # 交互式初始化配置(首次安装必跑;向导不再询问 skill 安装,见上)");
|
|
44
|
+
console.log(" rrs setup skills --agent opencode # 如需只分发部分 AI 助手(默认已全量分发)");
|
|
24
45
|
console.log(" rrs list-tools # 查看白名单与配置");
|
|
25
46
|
console.log("");
|
|
26
47
|
}
|
|
27
|
-
})();
|
|
48
|
+
})();
|
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.3",
|
|
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
|
|
|
@@ -34,11 +34,10 @@ metadata:
|
|
|
34
34
|
## Setup(用户一次性)
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
38
|
-
rrs init # 生成配置模板 ~/.rrs/config.json
|
|
37
|
+
npm install -g @zicolasjac-ai/remote-readonly-ssh # postinstall 自动把 SKILL.md 分发到 opencode / claude / agents 等目录
|
|
38
|
+
rrs init # 生成配置模板 ~/.rrs/config.json(TTY 下为交互向导)
|
|
39
39
|
# 编辑配置: auth_profiles(认证组,key/password 两种模式,可多个;direct:true 表示跳过堡垒机菜单直连裸机)
|
|
40
|
-
# target_hosts(IP → 认证组+搜索关键字,多 IP
|
|
41
|
-
rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
|
|
40
|
+
# target_hosts(IP → 认证组+搜索关键字,多 IP 可共用一组;可选 sftp_root 声明 koko 资产树目录)
|
|
42
41
|
```
|
|
43
42
|
|
|
44
43
|
## Usage
|
|
@@ -114,7 +113,7 @@ rrs upload <本地绝对路径> <远程绝对路径> [--overwrite|--resume] [--h
|
|
|
114
113
|
5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
|
|
115
114
|
6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
|
|
116
115
|
7. **传输约束(upload/download)**:
|
|
117
|
-
-
|
|
116
|
+
- 通道:`direct: true` 直连资产 = 真实文件系统;普通菜单资产 = **koko SFTP 网关**(连接后自动探测 koko 资产树定位该资产目录并映射语义路径;命名特殊时在 `target_hosts` 加 `"sftp_root": "模块/资产组/IP"` 显式声明;koko 模式不支持 `verify:"sha256"`,远端散列不可用)
|
|
118
117
|
- `upload` 是**写操作**:默认禁用;只有用户明确要求传文件且配置已启用(`file_transfer.upload.enabled: true` + 写路径白名单)时才可用;启用与否、白名单变更属配置修改,须先征得用户同意(同规则 6)
|
|
119
118
|
- `download` 无读白名单,但敏感路径防呆生效:内置(`/etc/shadow` 族、任意 `/.ssh/` 目录)+ `blocked_paths` 全局黑名单 + `file_transfer.download.blocked_paths` 追加;被拒即是禁止信号,**不要**换路径表达方式重试(可把需求转述给用户人工决策)
|
|
120
119
|
- `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
|
};
|