@zicolasjac-ai/remote-readonly-ssh 1.2.0 → 1.3.0
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 +7 -1
- package/bin/rrs.js +7 -4
- package/config.example.json +10 -0
- package/package.json +1 -1
- package/skills/remote-readonly-ssh/SKILL.md +2 -1
- package/src/config.js +6 -0
- package/src/init.js +42 -2
- package/src/jumpshell.js +86 -4
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
## 为什么需要它
|
|
8
8
|
|
|
9
9
|
- **技术强制只读**:命令前缀白名单 + 禁止模式 + 参数防注入 + 目标主机白名单,多层校验。
|
|
10
|
+
- **主机密钥防中间人**:TOFU 校验——首次连接自动记录堡垒机公钥指纹(`~/.rrs/known_hosts.json`),之后每次连接比对,不一致直接拒绝。
|
|
10
11
|
- **审计留痕**:每次调用(含被拒绝的记录)写入本地审计日志,可随时审查。
|
|
11
12
|
- **极简依赖**:连接层用纯 JS 的 `ssh2`(无原生编译),认证组支持 **SSH 私钥 / 密码** 两种模式,可按 IP 分组配置(N 台主机共用一组凭据),跨平台行为一致。
|
|
12
13
|
- **AI Agent 友好**:附带 SKILL.md 可一键安装到 opencode / Claude / Cursor / Windsurf / Codex / OpenHands 等 AI 助手的 skills 目录。
|
|
@@ -74,7 +75,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
74
75
|
|
|
75
76
|
| 键 | 说明 |
|
|
76
77
|
|-----|------|
|
|
77
|
-
| `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`list-tools` 只显示模式与主机,不显示任何凭据 |
|
|
78
|
+
| `auth_profiles` | **认证组**(可多个):`{组名: {host, port, username, auth, direct?}}`。`auth.mode` 支持 `key`(`private_key_path` 必填,可选 `private_key_passphrase`)与 `password`(`password` 必填)。`direct: true` 表示**直连裸机**(跳过堡垒机菜单,适合无堡垒机的服务器;命令只读校验与堡垒机模式完全一致)。`list-tools` 只显示模式与主机,不显示任何凭据 |
|
|
78
79
|
| `target_hosts` | 目标主机白名单 `{ip: {profile, keyword?}}`:`profile` 引用认证组名,`keyword` 为堡垒机资产搜索关键字(缺省用 IP)。**多台主机可共用同一认证组(N:1)** |
|
|
79
80
|
| `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
|
|
80
81
|
| `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
|
|
@@ -83,6 +84,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
83
84
|
| `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
|
|
84
85
|
|
|
85
86
|
> **凭据安全提示**:密码为明文存放于本机 `config.json`(与私钥文件同级信任,仅本机可读)。Windows 建议收紧 ACL,Linux/macOS 建议 `chmod 600 ~/.rrs/config.json`。
|
|
87
|
+
>
|
|
88
|
+
> **主机密钥校验**:首次连接堡垒机时自动记录其公钥指纹到 `~/.rrs/known_hosts.json`;此后密钥变化一律拒绝连接(防中间人)。若堡垒机确实已更换,删除该文件中对应条目重试即可重新记录。`init` 向导中密码输入**静音不回显**。
|
|
86
89
|
|
|
87
90
|
审计日志:`~/.rrs/audit.log`
|
|
88
91
|
|
|
@@ -132,6 +135,9 @@ npm test # 运行单元测试(node --test)
|
|
|
132
135
|
- **防绕过加固**:路径 `..` 穿越规范化、通配符 glob(`/etc/*/shadow`)、反斜杠转义、命令替换(`$()`、反引号)、变量展开(`$HOME`)、波浪号(`~`)、分隔符(`;` `&` `&&`)、换行注入等攻击面全部封堵;`hostname <name>`、`date -s` 等状态变更命令拒绝。
|
|
133
136
|
- **参数防注入**:`proc`/`ports`/`bin-version` 参数做严格字符集校验。
|
|
134
137
|
- **主机白名单**:未登记 IP 一律拒绝。
|
|
138
|
+
- **主机密钥 TOFU**:堡垒机公钥指纹首见记录(`~/.rrs/known_hosts.json`)、每次连接比对,变化即拒绝(防中间人窃取凭据/会话);连接错误消息中的凭据类内容自动脱敏。
|
|
139
|
+
- **凭据不落命令行**:认证走 ssh2 库内存参数,密码/私钥不出现在进程参数与环境变量中;审计日志只记命令,不含凭据。
|
|
140
|
+
- **直连模式同样受只读强制**:`direct: true` 仅跳过堡垒机菜单选资产环节,命令前缀白名单 / 禁止模式 / 路径黑名单 / 主机白名单 / 审计 / TOFU 对直连主机完全等效——防护不依赖堡垒机这道外部闸门。
|
|
135
141
|
- **无写原语**:工具集中不存在任何写操作路径;写操作由人工执行,本工具只读。
|
|
136
142
|
|
|
137
143
|
**已知边界**(设计取舍):
|
package/bin/rrs.js
CHANGED
|
@@ -126,7 +126,10 @@ function runSetup(opts) {
|
|
|
126
126
|
|
|
127
127
|
function initConfig() {
|
|
128
128
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
129
|
-
return initWizard.runInteractive()
|
|
129
|
+
return initWizard.runInteractive().catch((e) => {
|
|
130
|
+
console.error(`[init] ${e.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|
|
130
133
|
}
|
|
131
134
|
const dir = configDir();
|
|
132
135
|
const dest = path.join(dir, "config.json");
|
|
@@ -137,7 +140,7 @@ function initConfig() {
|
|
|
137
140
|
const tpl = fs.readFileSync(path.join(PACKAGE_ROOT, "config.example.json"), "utf8");
|
|
138
141
|
fs.writeFileSync(dest, tpl, "utf8");
|
|
139
142
|
console.log(`已生成配置模板: ${dest}`);
|
|
140
|
-
console.log("请填写
|
|
143
|
+
console.log("请填写 auth_profiles(认证组:SSH 私钥或密码)、target_hosts(目标主机)后使用。");
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
function buildCmds(tool, arg, conf) {
|
|
@@ -181,7 +184,7 @@ function listTools(conf) {
|
|
|
181
184
|
};
|
|
182
185
|
for (const [k, v] of Object.entries(tools)) console.log(` ${k.padEnd(20)} ${v}`);
|
|
183
186
|
const profiles = conf.auth_profiles || {};
|
|
184
|
-
console.log(`\n认证组: ${Object.entries(profiles).map(([id, p]) => `${id}(${p.auth.mode}@${p.host})`).join(", ") || "(无)"}`);
|
|
187
|
+
console.log(`\n认证组: ${Object.entries(profiles).map(([id, p]) => `${id}(${p.auth.mode}@${p.host}${p.direct ? ",direct" : ""})`).join(", ") || "(无)"}`);
|
|
185
188
|
console.log(`允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
|
|
186
189
|
console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
|
|
187
190
|
console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
|
|
@@ -234,7 +237,7 @@ async function main() {
|
|
|
234
237
|
|
|
235
238
|
const shell = new JumpShell(conf, host);
|
|
236
239
|
try {
|
|
237
|
-
shell.connect();
|
|
240
|
+
await shell.connect();
|
|
238
241
|
await shell.login();
|
|
239
242
|
const out = await shell.runCmds(cmds);
|
|
240
243
|
const cleaned = cleanAnsi(out);
|
package/config.example.json
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
"mode": "password",
|
|
19
19
|
"password": "YOUR_PASSWORD"
|
|
20
20
|
}
|
|
21
|
+
},
|
|
22
|
+
"profile_direct": {
|
|
23
|
+
"host": "YOUR_DIRECT_SERVER_IP",
|
|
24
|
+
"port": 22,
|
|
25
|
+
"username": "YOUR_USERNAME_3",
|
|
26
|
+
"direct": true,
|
|
27
|
+
"auth": {
|
|
28
|
+
"mode": "password",
|
|
29
|
+
"password": "YOUR_PASSWORD_2"
|
|
30
|
+
}
|
|
21
31
|
}
|
|
22
32
|
},
|
|
23
33
|
"target_hosts": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;认证组支持 SSH 私钥/密码两种模式与按 IP 分组;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,8 @@ metadata:
|
|
|
32
32
|
```bash
|
|
33
33
|
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
34
34
|
rrs init # 生成配置模板 ~/.rrs/config.json
|
|
35
|
-
# 编辑配置: auth_profiles(认证组,key/password
|
|
35
|
+
# 编辑配置: auth_profiles(认证组,key/password 两种模式,可多个;direct:true 表示跳过堡垒机菜单直连裸机)
|
|
36
|
+
# target_hosts(IP → 认证组+搜索关键字,多 IP 可共用一组)
|
|
36
37
|
rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
|
|
37
38
|
```
|
|
38
39
|
|
package/src/config.js
CHANGED
|
@@ -60,6 +60,12 @@ function validateConfig(conf) {
|
|
|
60
60
|
}
|
|
61
61
|
if (!p.host || typeof p.host !== "string") errors.push(`认证组 [${id}] 缺少 host`);
|
|
62
62
|
if (!p.username || typeof p.username !== "string") errors.push(`认证组 [${id}] 缺少 username`);
|
|
63
|
+
if (p.direct !== undefined && typeof p.direct !== "boolean") {
|
|
64
|
+
errors.push(`认证组 [${id}] direct 必须是布尔值(true=跳过堡垒机菜单直连)`);
|
|
65
|
+
}
|
|
66
|
+
if (p.port !== undefined && (!Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
|
|
67
|
+
errors.push(`认证组 [${id}] port 必须是 1-65535 的整数,当前: ${p.port}`);
|
|
68
|
+
}
|
|
63
69
|
const auth = p.auth;
|
|
64
70
|
if (!auth || typeof auth !== "object" || Array.isArray(auth)) {
|
|
65
71
|
errors.push(`认证组 [${id}] 缺少 auth {mode, ...}`);
|
package/src/init.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const os = require("os");
|
|
6
|
+
const { Writable } = require("node:stream");
|
|
6
7
|
const readline = require("node:readline/promises");
|
|
7
8
|
const setupSkill = require("./setup");
|
|
8
9
|
|
|
@@ -83,6 +84,45 @@ function defaultAnswersFor(cfg) {
|
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 静音输入(TTY 下不回显,防肩窥)。
|
|
89
|
+
* 原理:临时把 readline 的 output 替换为吞字节流(屏蔽 readline 的按键回显),
|
|
90
|
+
* 自行逐字节收集(支持退格);结束恢复原 output。
|
|
91
|
+
* 非标准环境(测试 mock / 管道,无 TTY)回退为普通 question(明文回显)。
|
|
92
|
+
*/
|
|
93
|
+
function askSecret(rl, q) {
|
|
94
|
+
const input = rl.input;
|
|
95
|
+
const output = rl.output;
|
|
96
|
+
if (!input || !output || input.isTTY !== true || typeof input.setRawMode !== "function") {
|
|
97
|
+
return Promise.resolve(rl.question(`${q}: `));
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const silent = new Writable({
|
|
101
|
+
write(_chunk, _enc, cb) { cb(); },
|
|
102
|
+
});
|
|
103
|
+
rl.output = silent;
|
|
104
|
+
const wasRaw = input.isRaw;
|
|
105
|
+
input.setRawMode(true);
|
|
106
|
+
output.write(`${q}: `);
|
|
107
|
+
let buf = "";
|
|
108
|
+
const finish = (fn) => {
|
|
109
|
+
input.removeListener("data", onData);
|
|
110
|
+
try { input.setRawMode(wasRaw); } catch (_) {}
|
|
111
|
+
rl.output = output;
|
|
112
|
+
output.write("\n");
|
|
113
|
+
fn();
|
|
114
|
+
};
|
|
115
|
+
const onData = (ch) => {
|
|
116
|
+
const s = ch.toString("utf8");
|
|
117
|
+
if (s === "\r" || s === "\n") return finish(() => resolve(buf));
|
|
118
|
+
if (s === "\u0003") return finish(() => reject(new Error("已取消输入")));
|
|
119
|
+
if (s === "\u007f" || s === "\b") { buf = buf.slice(0, -1); return; }
|
|
120
|
+
if (s >= " ") buf += s;
|
|
121
|
+
};
|
|
122
|
+
input.on("data", onData);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
async function collectAnswers(rl, cfg, prompt) {
|
|
87
127
|
const ask = async (q, def) => {
|
|
88
128
|
const label = def ? `${q} [${def}]` : q;
|
|
@@ -122,9 +162,9 @@ async function collectAnswers(rl, cfg, prompt) {
|
|
|
122
162
|
let password = "";
|
|
123
163
|
if (mode === "key") {
|
|
124
164
|
privateKeyPath = await ask(" SSH 私钥绝对路径", def.defaultKey || path.join(os.homedir(), ".ssh", "id_rsa"));
|
|
125
|
-
passphrase = await
|
|
165
|
+
passphrase = (await askSecret(rl, " 私钥口令(无则留空)")).trim();
|
|
126
166
|
} else {
|
|
127
|
-
password = await
|
|
167
|
+
password = (await askSecret(rl, " 登录密码")).trim();
|
|
128
168
|
if (!password) {
|
|
129
169
|
throw new Error("跳过:未填写登录密码。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
130
170
|
}
|
package/src/jumpshell.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const crypto = require("crypto");
|
|
4
6
|
const { Client } = require("ssh2");
|
|
7
|
+
const { configDir } = require("./config");
|
|
5
8
|
|
|
6
9
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
10
|
|
|
@@ -19,13 +22,49 @@ const ERR_PATTERNS = [
|
|
|
19
22
|
];
|
|
20
23
|
|
|
21
24
|
// ssh2 默认算法集已兼容 JumpServer 常见旧算法(ssh-rsa hostkey、diffie-hellman-group14-sha1 等),
|
|
22
|
-
//
|
|
25
|
+
// 客户端列表强算法优先,无需额外放宽。
|
|
23
26
|
const CONNECT_OPTIONS = {
|
|
24
27
|
readyTimeout: 20000,
|
|
25
28
|
keepaliveInterval: 10000,
|
|
26
|
-
hostVerifier: () => true,
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
// ==== 主机密钥 TOFU(trust-on-first-use)====
|
|
32
|
+
// 等价并强于原 ssh -o StrictHostKeyChecking=no:首次连接记录堡垒机公钥指纹到
|
|
33
|
+
// ~/.rrs/known_hosts.json,之后每次连接比对,不一致则拒绝(防中间人长期冒充)。
|
|
34
|
+
|
|
35
|
+
function knownHostsPath(dir) {
|
|
36
|
+
return path.join(dir || configDir(), "known_hosts.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadKnownHosts(dir) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(knownHostsPath(dir), "utf8"));
|
|
42
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
43
|
+
} catch (_) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveKnownHosts(store, dir) {
|
|
49
|
+
const target = knownHostsPath(dir);
|
|
50
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
51
|
+
fs.writeFileSync(target, JSON.stringify(store, null, 2), "utf8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hostKeyFingerprint(hostkey) {
|
|
55
|
+
return "sha256:" + crypto.createHash("sha256").update(hostkey).digest("base64");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 纯函数:比对主机密钥。首见返回 isNew=true(调用方负责持久化),
|
|
60
|
+
* 已知且匹配返回 ok=true;已知但不匹配返回 ok=false(应拒绝连接)。
|
|
61
|
+
*/
|
|
62
|
+
function checkHostKey(store, endpointId, hostkey) {
|
|
63
|
+
const fp = hostKeyFingerprint(hostkey);
|
|
64
|
+
if (!store[endpointId]) return { ok: true, fp, isNew: true };
|
|
65
|
+
return { ok: store[endpointId] === fp, fp, isNew: false, known: store[endpointId] };
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
/**
|
|
30
69
|
* 从配置解析某目标主机所属的认证组(auth_profiles 条目)。
|
|
31
70
|
* target_hosts[host] = { profile, keyword };keyword 缺省回退为 host 本身。
|
|
@@ -58,6 +97,12 @@ function mapConnError(e) {
|
|
|
58
97
|
/(password|passwd)[=:\s][^\s,;]+/gi,
|
|
59
98
|
"$1=***"
|
|
60
99
|
);
|
|
100
|
+
if (/host key verification|host denied/i.test(msg)) {
|
|
101
|
+
return new Error(
|
|
102
|
+
"堡垒机主机密钥与首次记录不一致(已拒绝,防中间人)。" +
|
|
103
|
+
"若堡垒机确实已更换,请删除 ~/.rrs/known_hosts.json 中对应条目后重试"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
61
106
|
if (/authentication|permission denied/i.test(msg)) {
|
|
62
107
|
return new Error(`SSH 认证失败: ${msg}(请检查 auth_profiles 的账号/密码/私钥)`);
|
|
63
108
|
}
|
|
@@ -72,12 +117,15 @@ function mapConnError(e) {
|
|
|
72
117
|
* 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
|
|
73
118
|
*/
|
|
74
119
|
class JumpShell {
|
|
75
|
-
constructor(conf, host) {
|
|
120
|
+
constructor(conf, host, opts = {}) {
|
|
76
121
|
this.host = host;
|
|
77
122
|
this.conf = conf;
|
|
78
123
|
this.entry = resolveHostEntry(conf, host);
|
|
79
124
|
this.profile = resolveProfile(conf, host);
|
|
80
125
|
this.searchKeyword = this.entry.keyword || host;
|
|
126
|
+
// direct=true:跳过堡垒机菜单直连裸机(无 Opt> 流程);命令只读校验链路不变
|
|
127
|
+
this.direct = this.profile.direct === true;
|
|
128
|
+
this.knownHostsDir = opts.knownHostsDir; // 测试注入用;缺省 ~/.rrs
|
|
81
129
|
this.conn = null;
|
|
82
130
|
this.stream = null;
|
|
83
131
|
this.out = "";
|
|
@@ -96,11 +144,30 @@ class JumpShell {
|
|
|
96
144
|
};
|
|
97
145
|
if (p.auth.mode === "password") {
|
|
98
146
|
cfg.password = p.auth.password;
|
|
147
|
+
// 兼容强制 keyboard-interactive 的堡垒机:用同一密码自动应答
|
|
148
|
+
cfg.tryKeyboard = true;
|
|
99
149
|
} else {
|
|
100
150
|
cfg.privateKey = fs.readFileSync(p.auth.private_key_path);
|
|
101
151
|
if (p.auth.private_key_passphrase) cfg.passphrase = p.auth.private_key_passphrase;
|
|
102
152
|
}
|
|
103
153
|
|
|
154
|
+
// 主机密钥 TOFU 校验:首见记录,不一致拒绝。
|
|
155
|
+
// ssh2 用户回调签名固定为 hostVerifier(key, verify)(key 为服务器公钥 Buffer)。
|
|
156
|
+
cfg.hostVerifier = (hostkey, verify) => {
|
|
157
|
+
try {
|
|
158
|
+
const store = loadKnownHosts(this.knownHostsDir);
|
|
159
|
+
const endpointId = `${cfg.host}:${cfg.port}`;
|
|
160
|
+
const r = checkHostKey(store, endpointId, hostkey);
|
|
161
|
+
if (r.isNew) {
|
|
162
|
+
store[endpointId] = r.fp;
|
|
163
|
+
saveKnownHosts(store, this.knownHostsDir);
|
|
164
|
+
}
|
|
165
|
+
verify(r.ok);
|
|
166
|
+
} catch (_) {
|
|
167
|
+
verify(false);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
104
171
|
return new Promise((resolve, reject) => {
|
|
105
172
|
const conn = new Client();
|
|
106
173
|
this.conn = conn;
|
|
@@ -109,6 +176,11 @@ class JumpShell {
|
|
|
109
176
|
reject(mapConnError(e));
|
|
110
177
|
};
|
|
111
178
|
conn.on("error", fail);
|
|
179
|
+
if (p.auth.mode === "password") {
|
|
180
|
+
conn.on("keyboard-interactive", (_name, _instr, _lang, _prompts, finish) => {
|
|
181
|
+
try { finish([cfg.password || ""]); } catch (_) {}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
112
184
|
conn.on("ready", () => {
|
|
113
185
|
conn.shell({ term: "xterm", rows: 30, cols: 120 }, (err, stream) => {
|
|
114
186
|
if (err) return fail(err);
|
|
@@ -180,6 +252,12 @@ class JumpShell {
|
|
|
180
252
|
|
|
181
253
|
async login(timeoutSecs = 30) {
|
|
182
254
|
const deadline = Date.now() + timeoutSecs * 1000;
|
|
255
|
+
if (this.direct) {
|
|
256
|
+
// 直连模式:连上即 shell,只等 prompt;命令仍走白名单校验(whitelist.js)
|
|
257
|
+
if (!(await this.waitPrompt(deadline))) throw new Error("未能进入 shell(直连)");
|
|
258
|
+
await sleep(500);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
183
261
|
if (!(await this.waitFor("Opt>", deadline))) throw new Error("跳板机菜单未出现");
|
|
184
262
|
await sleep(1000);
|
|
185
263
|
this.send("");
|
|
@@ -215,4 +293,8 @@ function cleanAnsi(t) {
|
|
|
215
293
|
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // CSI 序列(颜色/光标)
|
|
216
294
|
}
|
|
217
295
|
|
|
218
|
-
module.exports = {
|
|
296
|
+
module.exports = {
|
|
297
|
+
JumpShell, cleanAnsi, sleep, PROMPT_RE,
|
|
298
|
+
resolveHostEntry, resolveProfile, mapConnError,
|
|
299
|
+
knownHostsPath, loadKnownHosts, saveKnownHosts, hostKeyFingerprint, checkHostKey,
|
|
300
|
+
};
|