novac 2.1.1 → 2.2.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/LICENSE +1 -1
- package/README.md +0 -0
- package/demo.nv +0 -0
- package/demo_builtins.nv +0 -0
- package/demo_http.nv +0 -0
- package/examples/bf.nv +69 -0
- package/examples/math.nv +21 -0
- package/kits/birdAPI/kitdef.js +954 -0
- package/kits/kitRNG/kitdef.js +740 -0
- package/kits/kitSSH/kitdef.js +1272 -0
- package/kits/kitadb/kitdef.js +606 -0
- package/kits/kitai/kitdef.js +2185 -0
- package/kits/kitcanvas/kitdef.js +914 -0
- package/kits/kitclippy/kitdef.js +925 -0
- package/kits/kitgps/kitdef.js +1862 -0
- package/kits/kitlibproc/kitdef.js +3 -2
- package/kits/kitmorse/kitdef.js +229 -0
- package/kits/kitmpatch/kitdef.js +906 -0
- package/kits/kitnet/kitdef.js +1401 -0
- package/kits/kitproto/kitdef.js +613 -0
- package/kits/kitqr/kitdef.js +637 -0
- package/kits/kitrequire/kitdef.js +1599 -0
- package/kits/libtea/kitdef.js +2691 -0
- package/kits/libterm/kitdef.js +2 -0
- package/novac/LICENSE +21 -0
- package/novac/README.md +1823 -0
- package/novac/bin/novac +950 -0
- package/novac/bin/nvc +522 -0
- package/novac/bin/nvml +542 -0
- package/novac/demo.nv +245 -0
- package/novac/demo_builtins.nv +209 -0
- package/novac/demo_http.nv +62 -0
- package/novac/examples/bf.nv +69 -0
- package/novac/examples/math.nv +21 -0
- package/novac/kits/kitai/kitdef.js +2185 -0
- package/novac/kits/kitansi/kitdef.js +1402 -0
- package/novac/kits/kitformat/kitdef.js +1485 -0
- package/novac/kits/kitgps/kitdef.js +1862 -0
- package/novac/kits/kitlibfs/kitdef.js +231 -0
- package/{examples/example-project/nova_modules → novac/kits}/kitlibproc/kitdef.js +3 -2
- package/novac/kits/kitmatrix/ex.js +19 -0
- package/novac/kits/kitmatrix/kitdef.js +960 -0
- package/novac/kits/kitmpatch/kitdef.js +906 -0
- package/novac/kits/kitnovacweb/README.md +1572 -0
- package/novac/kits/kitnovacweb/demo.nv +12 -0
- package/novac/kits/kitnovacweb/demo.nvml +71 -0
- package/novac/kits/kitnovacweb/index.nova +12 -0
- package/novac/kits/kitnovacweb/kitdef.js +692 -0
- package/novac/kits/kitnovacweb/nova.kit.json +8 -0
- package/novac/kits/kitnovacweb/nvml/executor.js +739 -0
- package/novac/kits/kitnovacweb/nvml/index.js +67 -0
- package/novac/kits/kitnovacweb/nvml/lexer.js +263 -0
- package/novac/kits/kitnovacweb/nvml/parser.js +508 -0
- package/novac/kits/kitnovacweb/nvml/renderer.js +924 -0
- package/novac/kits/kitparse/kitdef.js +1688 -0
- package/novac/kits/kitregex++/kitdef.js +1353 -0
- package/novac/kits/kitrequire/kitdef.js +1599 -0
- package/novac/kits/kitx11/kitdef.js +1 -0
- package/novac/kits/kitx11/kitx11.js +2472 -0
- package/novac/kits/kitx11/kitx11_conn.js +948 -0
- package/novac/kits/kitx11/kitx11_worker.js +121 -0
- package/novac/kits/libterm/ex.js +285 -0
- package/novac/kits/libterm/kitdef.js +1927 -0
- package/novac/node_modules/chalk/license +9 -0
- package/novac/node_modules/chalk/package.json +83 -0
- package/novac/node_modules/chalk/readme.md +297 -0
- package/novac/node_modules/chalk/source/index.d.ts +325 -0
- package/novac/node_modules/chalk/source/index.js +225 -0
- package/novac/node_modules/chalk/source/utilities.js +33 -0
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
- package/novac/node_modules/commander/LICENSE +22 -0
- package/novac/node_modules/commander/Readme.md +1176 -0
- package/novac/node_modules/commander/esm.mjs +16 -0
- package/novac/node_modules/commander/index.js +24 -0
- package/novac/node_modules/commander/lib/argument.js +150 -0
- package/novac/node_modules/commander/lib/command.js +2777 -0
- package/novac/node_modules/commander/lib/error.js +39 -0
- package/novac/node_modules/commander/lib/help.js +747 -0
- package/novac/node_modules/commander/lib/option.js +380 -0
- package/novac/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/novac/node_modules/commander/package-support.json +19 -0
- package/novac/node_modules/commander/package.json +82 -0
- package/novac/node_modules/commander/typings/esm.d.mts +3 -0
- package/novac/node_modules/commander/typings/index.d.ts +1113 -0
- package/novac/node_modules/node-addon-api/LICENSE.md +9 -0
- package/novac/node_modules/node-addon-api/README.md +95 -0
- package/novac/node_modules/node-addon-api/common.gypi +21 -0
- package/novac/node_modules/node-addon-api/except.gypi +25 -0
- package/novac/node_modules/node-addon-api/index.js +14 -0
- package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
- package/novac/node_modules/node-addon-api/napi-inl.h +7165 -0
- package/novac/node_modules/node-addon-api/napi.h +3364 -0
- package/novac/node_modules/node-addon-api/node_addon_api.gyp +42 -0
- package/novac/node_modules/node-addon-api/node_api.gyp +9 -0
- package/novac/node_modules/node-addon-api/noexcept.gypi +26 -0
- package/novac/node_modules/node-addon-api/package-support.json +21 -0
- package/novac/node_modules/node-addon-api/package.json +480 -0
- package/novac/node_modules/node-addon-api/tools/README.md +73 -0
- package/novac/node_modules/node-addon-api/tools/check-napi.js +99 -0
- package/novac/node_modules/node-addon-api/tools/clang-format.js +71 -0
- package/novac/node_modules/node-addon-api/tools/conversion.js +301 -0
- package/novac/node_modules/serialize-javascript/LICENSE +27 -0
- package/novac/node_modules/serialize-javascript/README.md +149 -0
- package/novac/node_modules/serialize-javascript/index.js +297 -0
- package/novac/node_modules/serialize-javascript/package.json +33 -0
- package/novac/package.json +27 -0
- package/novac/scripts/update-bin.js +24 -0
- package/novac/src/core/bstd.js +1035 -0
- package/novac/src/core/config.js +155 -0
- package/novac/src/core/describe.js +187 -0
- package/novac/src/core/emitter.js +499 -0
- package/novac/src/core/error.js +86 -0
- package/novac/src/core/executor.js +5606 -0
- package/novac/src/core/formatter.js +686 -0
- package/novac/src/core/lexer.js +1026 -0
- package/novac/src/core/nova_builtins.js +717 -0
- package/novac/src/core/nova_thread_worker.js +166 -0
- package/novac/src/core/parser.js +2181 -0
- package/novac/src/core/types.js +112 -0
- package/novac/src/index.js +28 -0
- package/novac/src/runtime/stdlib.js +244 -0
- package/package.json +3 -2
- package/scripts/update-bin.js +0 -0
- package/src/core/bstd.js +835 -361
- package/src/core/executor.js +427 -246
- package/src/core/lexer.js +19 -2
- package/src/core/parser.js +13 -0
- package/src/index.js +0 -0
- package/examples/example-project/README.md +0 -3
- package/examples/example-project/src/main.nova +0 -3
- package/src/core/environment.js +0 -0
- /package/{kits → novac/kits}/libtea/tf.js +0 -0
- /package/{examples/example-project/bin/example-project.nv → novac/node_modules/node-addon-api/nothing.c} +0 -0
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
// kitdef.js — kitSSH
|
|
2
|
+
// A complete OpenSSH-equivalent kit for the standard library.
|
|
3
|
+
// Covers: connections, sessions, command execution, key management,
|
|
4
|
+
// tunneling, port forwarding, SCP/SFTP, agent forwarding,
|
|
5
|
+
// multiplexing, jump hosts, known_hosts, authorized_keys, and more.
|
|
6
|
+
//
|
|
7
|
+
// Backed by the `ssh2` npm package for real execution.
|
|
8
|
+
// Every method returns a structured result object.
|
|
9
|
+
//
|
|
10
|
+
// const { kitSSH } = require('./kitdef').kitdef;
|
|
11
|
+
|
|
12
|
+
// ── Dependencies (real execution layer) ─────────────────────────────────────
|
|
13
|
+
// npm install ssh2
|
|
14
|
+
|
|
15
|
+
let _ssh2;
|
|
16
|
+
try { _ssh2 = require("ssh2"); } catch { _ssh2 = null; }
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const os = require("os");
|
|
21
|
+
const { execSync, spawn } = require("child_process");
|
|
22
|
+
const crypto = require("crypto");
|
|
23
|
+
const net = require("net");
|
|
24
|
+
|
|
25
|
+
// ── Internal state ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const _connections = new Map(); // id → { client, config, tunnels, sftp }
|
|
28
|
+
const _knownHosts = new Map(); // "host:port" → { fingerprint, addedAt }
|
|
29
|
+
const _agentSockets = new Map(); // id → socketPath
|
|
30
|
+
const _pool = new Map(); // poolKey → Connection[]
|
|
31
|
+
let _connectionCounter = 1;
|
|
32
|
+
|
|
33
|
+
function _connId() { return `ssh-conn-${_connectionCounter++}`; }
|
|
34
|
+
function _poolKey(config) { return `${config.username}@${config.host}:${config.port || 22}`; }
|
|
35
|
+
|
|
36
|
+
function _requireSsh2() {
|
|
37
|
+
if (!_ssh2) throw new Error("ssh2 package not installed. Run: npm install ssh2");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _getConn(id) {
|
|
41
|
+
const c = _connections.get(id);
|
|
42
|
+
if (!c) throw new Error(`No active connection "${id}". Call connect() first.`);
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _fingerprint(key) {
|
|
47
|
+
return crypto.createHash("sha256").update(key).digest("base64");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _sshDir() {
|
|
51
|
+
return path.join(os.homedir(), ".ssh");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Export ───────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
kitdef: {
|
|
58
|
+
|
|
59
|
+
name: "kitSSH",
|
|
60
|
+
version: "1.0.0",
|
|
61
|
+
description: "Complete OpenSSH kit: connections, sessions, exec, keys, tunnels, SFTP, SCP, agent, multiplexing, jump hosts, and more.",
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
64
|
+
// CONNECTION MANAGEMENT
|
|
65
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Open an SSH connection to a remote host.
|
|
69
|
+
* Returns a connection ID used by all subsequent operations.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} config
|
|
72
|
+
* @param {string} config.host
|
|
73
|
+
* @param {number} [config.port=22]
|
|
74
|
+
* @param {string} config.username
|
|
75
|
+
* @param {string} [config.password]
|
|
76
|
+
* @param {string} [config.privateKey] - Path to private key file, or raw key string.
|
|
77
|
+
* @param {string} [config.passphrase] - Passphrase for encrypted private key.
|
|
78
|
+
* @param {number} [config.timeout=10000] - Connection timeout ms.
|
|
79
|
+
* @param {boolean} [config.agentForward=false]
|
|
80
|
+
* @param {string} [config.jumpHost] - ProxyJump host connection ID.
|
|
81
|
+
* @returns {Promise<{ id: string, host: string, port: number, username: string, status: string }>}
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const { id } = await kitSSH.connect({
|
|
85
|
+
* host: "192.168.1.10",
|
|
86
|
+
* username: "admin",
|
|
87
|
+
* privateKey: "~/.ssh/id_ed25519",
|
|
88
|
+
* });
|
|
89
|
+
*/
|
|
90
|
+
connect(config) {
|
|
91
|
+
_requireSsh2();
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const id = _connId();
|
|
94
|
+
const client = new _ssh2.Client();
|
|
95
|
+
const port = config.port || 22;
|
|
96
|
+
const timeout = config.timeout || 10000;
|
|
97
|
+
|
|
98
|
+
let privateKey = config.privateKey;
|
|
99
|
+
if (privateKey && !privateKey.includes("-----")) {
|
|
100
|
+
const expanded = privateKey.replace(/^~/, os.homedir());
|
|
101
|
+
privateKey = fs.readFileSync(expanded, "utf8");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sshConfig = {
|
|
105
|
+
host: config.host,
|
|
106
|
+
port,
|
|
107
|
+
username: config.username,
|
|
108
|
+
password: config.password,
|
|
109
|
+
privateKey: privateKey || undefined,
|
|
110
|
+
passphrase: config.passphrase,
|
|
111
|
+
readyTimeout: timeout,
|
|
112
|
+
agentForward: config.agentForward || false,
|
|
113
|
+
agent: config.agentForward ? (process.env.SSH_AUTH_SOCK || undefined) : undefined,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ProxyJump / jump host
|
|
117
|
+
if (config.jumpHost) {
|
|
118
|
+
const jump = _getConn(config.jumpHost);
|
|
119
|
+
jump.client.forwardOut("127.0.0.1", 0, config.host, port, (err, stream) => {
|
|
120
|
+
if (err) return reject(err);
|
|
121
|
+
sshConfig.sock = stream;
|
|
122
|
+
_finishConnect(client, sshConfig, id, config, resolve, reject);
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
_finishConnect(client, sshConfig, id, config, resolve, reject);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Disconnect a session by connection ID.
|
|
132
|
+
* @param {string} id
|
|
133
|
+
* @returns {{ id, status }}
|
|
134
|
+
*/
|
|
135
|
+
disconnect(id) {
|
|
136
|
+
const c = _connections.get(id);
|
|
137
|
+
if (c) {
|
|
138
|
+
c.client.end();
|
|
139
|
+
_connections.delete(id);
|
|
140
|
+
}
|
|
141
|
+
return { id, status: "DISCONNECTED" };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Disconnect all open connections.
|
|
146
|
+
* @returns {{ disconnected: number }}
|
|
147
|
+
*/
|
|
148
|
+
disconnectAll() {
|
|
149
|
+
let count = 0;
|
|
150
|
+
for (const [id, c] of _connections) {
|
|
151
|
+
c.client.end();
|
|
152
|
+
_connections.delete(id);
|
|
153
|
+
count++;
|
|
154
|
+
}
|
|
155
|
+
return { disconnected: count };
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check whether a connection is still alive.
|
|
160
|
+
* @param {string} id
|
|
161
|
+
* @returns {{ id, alive: boolean }}
|
|
162
|
+
*/
|
|
163
|
+
isAlive(id) {
|
|
164
|
+
const c = _connections.get(id);
|
|
165
|
+
return { id, alive: !!c };
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* List all currently open connections.
|
|
170
|
+
* @returns {object[]}
|
|
171
|
+
*/
|
|
172
|
+
listConnections() {
|
|
173
|
+
return Array.from(_connections.entries()).map(([id, c]) => ({
|
|
174
|
+
id,
|
|
175
|
+
host: c.config.host,
|
|
176
|
+
port: c.config.port || 22,
|
|
177
|
+
username: c.config.username,
|
|
178
|
+
tunnelCount: c.tunnels ? c.tunnels.length : 0,
|
|
179
|
+
openedAt: c.openedAt,
|
|
180
|
+
}));
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get detailed info about a specific connection.
|
|
185
|
+
* @param {string} id
|
|
186
|
+
* @returns {object}
|
|
187
|
+
*/
|
|
188
|
+
connectionInfo(id) {
|
|
189
|
+
const c = _getConn(id);
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
host: c.config.host,
|
|
193
|
+
port: c.config.port || 22,
|
|
194
|
+
username: c.config.username,
|
|
195
|
+
agentForward: c.config.agentForward || false,
|
|
196
|
+
tunnels: c.tunnels || [],
|
|
197
|
+
openedAt: c.openedAt,
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
202
|
+
// COMMAND EXECUTION
|
|
203
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Execute a command on a remote host and return stdout/stderr.
|
|
207
|
+
* @param {string} id - Connection ID.
|
|
208
|
+
* @param {string} command
|
|
209
|
+
* @param {object} [options]
|
|
210
|
+
* @param {object} [options.env] - Environment variables.
|
|
211
|
+
* @param {boolean}[options.pty=false] - Request a pseudo-TTY.
|
|
212
|
+
* @returns {Promise<{ stdout: string, stderr: string, code: number, signal?: string }>}
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const { stdout } = await kitSSH.exec(id, "uname -a");
|
|
216
|
+
*/
|
|
217
|
+
exec(id, command, options = {}) {
|
|
218
|
+
const c = _getConn(id);
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const execOptions = { env: options.env };
|
|
221
|
+
if (options.pty) execOptions.pty = true;
|
|
222
|
+
|
|
223
|
+
c.client.exec(command, execOptions, (err, stream) => {
|
|
224
|
+
if (err) return reject(err);
|
|
225
|
+
let stdout = "", stderr = "";
|
|
226
|
+
stream.on("data", d => { stdout += d; });
|
|
227
|
+
stream.stderr.on("data", d => { stderr += d; });
|
|
228
|
+
stream.on("close", (code, signal) => resolve({ stdout, stderr, code, signal }));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Execute multiple commands sequentially on the same connection.
|
|
235
|
+
* @param {string} id
|
|
236
|
+
* @param {string[]} commands
|
|
237
|
+
* @returns {Promise<Array<{ command, stdout, stderr, code }>>}
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* await kitSSH.execMany(id, ["apt update", "apt upgrade -y", "reboot"]);
|
|
241
|
+
*/
|
|
242
|
+
async execMany(id, commands) {
|
|
243
|
+
const results = [];
|
|
244
|
+
for (const command of commands) {
|
|
245
|
+
const result = await this.exec(id, command);
|
|
246
|
+
results.push({ command, ...result });
|
|
247
|
+
}
|
|
248
|
+
return results;
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Execute a command and stream output line-by-line via a callback.
|
|
253
|
+
* @param {string} id
|
|
254
|
+
* @param {string} command
|
|
255
|
+
* @param {{ onStdout?: function, onStderr?: function }} callbacks
|
|
256
|
+
* @returns {Promise<{ code: number }>}
|
|
257
|
+
*/
|
|
258
|
+
execStream(id, command, { onStdout, onStderr } = {}) {
|
|
259
|
+
const c = _getConn(id);
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
c.client.exec(command, (err, stream) => {
|
|
262
|
+
if (err) return reject(err);
|
|
263
|
+
stream.on("data", chunk => {
|
|
264
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
265
|
+
lines.forEach(line => onStdout && onStdout(line));
|
|
266
|
+
});
|
|
267
|
+
stream.stderr.on("data", chunk => {
|
|
268
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
269
|
+
lines.forEach(line => onStderr && onStderr(line));
|
|
270
|
+
});
|
|
271
|
+
stream.on("close", code => resolve({ code }));
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Open an interactive shell session.
|
|
278
|
+
* @param {string} id
|
|
279
|
+
* @param {object} [options]
|
|
280
|
+
* @param {object} [options.ptyOptions] - e.g. { term: "xterm-256color", cols: 220, rows: 50 }
|
|
281
|
+
* @returns {Promise<stream>} - A duplex stream you can pipe to/from.
|
|
282
|
+
*/
|
|
283
|
+
shell(id, options = {}) {
|
|
284
|
+
const c = _getConn(id);
|
|
285
|
+
const ptyOptions = options.ptyOptions || { term: "xterm-256color", cols: 220, rows: 50 };
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
c.client.shell(ptyOptions, (err, stream) => {
|
|
288
|
+
if (err) return reject(err);
|
|
289
|
+
resolve(stream);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Execute a command with a timeout. Kills the remote process if exceeded.
|
|
296
|
+
* @param {string} id
|
|
297
|
+
* @param {string} command
|
|
298
|
+
* @param {number} timeoutMs
|
|
299
|
+
* @returns {Promise<{ stdout, stderr, code, timedOut }>}
|
|
300
|
+
*/
|
|
301
|
+
execTimeout(id, command, timeoutMs) {
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
const c = _getConn(id);
|
|
304
|
+
let timedOut = false;
|
|
305
|
+
let stdout = "", stderr = "";
|
|
306
|
+
|
|
307
|
+
c.client.exec(command, (err, stream) => {
|
|
308
|
+
if (err) return reject(err);
|
|
309
|
+
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
timedOut = true;
|
|
312
|
+
stream.close();
|
|
313
|
+
}, timeoutMs);
|
|
314
|
+
|
|
315
|
+
stream.on("data", d => { stdout += d; });
|
|
316
|
+
stream.stderr.on("data", d => { stderr += d; });
|
|
317
|
+
stream.on("close", code => {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Run a local script file on the remote host.
|
|
327
|
+
* Uploads it, executes it, then removes it.
|
|
328
|
+
* @param {string} id
|
|
329
|
+
* @param {string} localScriptPath
|
|
330
|
+
* @param {string[]} [args]
|
|
331
|
+
* @returns {Promise<{ stdout, stderr, code }>}
|
|
332
|
+
*/
|
|
333
|
+
async runScript(id, localScriptPath, args = []) {
|
|
334
|
+
const remotePath = `/tmp/_kitSSH_script_${Date.now()}.sh`;
|
|
335
|
+
await this.upload(id, localScriptPath, remotePath);
|
|
336
|
+
await this.exec(id, `chmod +x ${remotePath}`);
|
|
337
|
+
const result = await this.exec(id, `${remotePath} ${args.join(" ")}`);
|
|
338
|
+
await this.exec(id, `rm -f ${remotePath}`);
|
|
339
|
+
return result;
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Run a command as a different user via sudo.
|
|
344
|
+
* @param {string} id
|
|
345
|
+
* @param {string} command
|
|
346
|
+
* @param {string} [sudoPassword]
|
|
347
|
+
* @returns {Promise<{ stdout, stderr, code }>}
|
|
348
|
+
*/
|
|
349
|
+
sudo(id, command, sudoPassword) {
|
|
350
|
+
const wrapped = sudoPassword
|
|
351
|
+
? `echo '${sudoPassword}' | sudo -S ${command}`
|
|
352
|
+
: `sudo ${command}`;
|
|
353
|
+
return this.exec(id, wrapped);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
357
|
+
// SFTP / FILE TRANSFER
|
|
358
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Open an SFTP session on an existing connection.
|
|
362
|
+
* @param {string} id
|
|
363
|
+
* @returns {Promise<sftp>}
|
|
364
|
+
*/
|
|
365
|
+
openSFTP(id) {
|
|
366
|
+
const c = _getConn(id);
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
c.client.sftp((err, sftp) => {
|
|
369
|
+
if (err) return reject(err);
|
|
370
|
+
c.sftp = sftp;
|
|
371
|
+
resolve(sftp);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Upload a local file to the remote host.
|
|
378
|
+
* @param {string} id
|
|
379
|
+
* @param {string} localPath
|
|
380
|
+
* @param {string} remotePath
|
|
381
|
+
* @param {{ onProgress?: function }} [options]
|
|
382
|
+
* @returns {Promise<{ localPath, remotePath, status }>}
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* await kitSSH.upload(id, "./deploy.sh", "/home/admin/deploy.sh");
|
|
386
|
+
*/
|
|
387
|
+
async upload(id, localPath, remotePath, options = {}) {
|
|
388
|
+
const sftp = await this.openSFTP(id);
|
|
389
|
+
return new Promise((resolve, reject) => {
|
|
390
|
+
const readStream = fs.createReadStream(localPath);
|
|
391
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
392
|
+
if (options.onProgress) {
|
|
393
|
+
let transferred = 0;
|
|
394
|
+
const total = fs.statSync(localPath).size;
|
|
395
|
+
readStream.on("data", chunk => {
|
|
396
|
+
transferred += chunk.length;
|
|
397
|
+
options.onProgress({ transferred, total, percent: (transferred / total * 100).toFixed(1) });
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
writeStream.on("close", () => resolve({ localPath, remotePath, status: "UPLOADED" }));
|
|
401
|
+
writeStream.on("error", reject);
|
|
402
|
+
readStream.pipe(writeStream);
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Download a remote file to local disk.
|
|
408
|
+
* @param {string} id
|
|
409
|
+
* @param {string} remotePath
|
|
410
|
+
* @param {string} localPath
|
|
411
|
+
* @param {{ onProgress?: function }} [options]
|
|
412
|
+
* @returns {Promise<{ remotePath, localPath, status }>}
|
|
413
|
+
*/
|
|
414
|
+
async download(id, remotePath, localPath, options = {}) {
|
|
415
|
+
const sftp = await this.openSFTP(id);
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const readStream = sftp.createReadStream(remotePath);
|
|
418
|
+
const writeStream = fs.createWriteStream(localPath);
|
|
419
|
+
readStream.on("error", reject);
|
|
420
|
+
writeStream.on("close", () => resolve({ remotePath, localPath, status: "DOWNLOADED" }));
|
|
421
|
+
readStream.pipe(writeStream);
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Upload an entire directory recursively.
|
|
427
|
+
* @param {string} id
|
|
428
|
+
* @param {string} localDir
|
|
429
|
+
* @param {string} remoteDir
|
|
430
|
+
* @returns {Promise<{ uploaded: string[] }>}
|
|
431
|
+
*/
|
|
432
|
+
async uploadDir(id, localDir, remoteDir) {
|
|
433
|
+
const uploaded = [];
|
|
434
|
+
const walk = async (local, remote) => {
|
|
435
|
+
await this.exec(id, `mkdir -p ${remote}`);
|
|
436
|
+
for (const entry of fs.readdirSync(local, { withFileTypes: true })) {
|
|
437
|
+
const lp = path.join(local, entry.name);
|
|
438
|
+
const rp = `${remote}/${entry.name}`;
|
|
439
|
+
if (entry.isDirectory()) {
|
|
440
|
+
await walk(lp, rp);
|
|
441
|
+
} else {
|
|
442
|
+
await this.upload(id, lp, rp);
|
|
443
|
+
uploaded.push(rp);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
await walk(localDir, remoteDir);
|
|
448
|
+
return { uploaded };
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* List files in a remote directory.
|
|
453
|
+
* @param {string} id
|
|
454
|
+
* @param {string} remotePath
|
|
455
|
+
* @returns {Promise<object[]>}
|
|
456
|
+
*/
|
|
457
|
+
async listDir(id, remotePath) {
|
|
458
|
+
const sftp = await this.openSFTP(id);
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
461
|
+
if (err) return reject(err);
|
|
462
|
+
resolve(list.map(f => ({
|
|
463
|
+
name: f.filename,
|
|
464
|
+
size: f.attrs.size,
|
|
465
|
+
mode: f.attrs.mode,
|
|
466
|
+
modified: new Date(f.attrs.mtime * 1000).toISOString(),
|
|
467
|
+
isDirectory: (f.attrs.mode & 0o40000) !== 0,
|
|
468
|
+
})));
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Read a remote file's contents into a string.
|
|
475
|
+
* @param {string} id
|
|
476
|
+
* @param {string} remotePath
|
|
477
|
+
* @returns {Promise<string>}
|
|
478
|
+
*/
|
|
479
|
+
async readFile(id, remotePath) {
|
|
480
|
+
const sftp = await this.openSFTP(id);
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
const chunks = [];
|
|
483
|
+
const stream = sftp.createReadStream(remotePath);
|
|
484
|
+
stream.on("data", d => chunks.push(d));
|
|
485
|
+
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
486
|
+
stream.on("error", reject);
|
|
487
|
+
});
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Write a string to a remote file.
|
|
492
|
+
* @param {string} id
|
|
493
|
+
* @param {string} remotePath
|
|
494
|
+
* @param {string} content
|
|
495
|
+
* @returns {Promise<{ remotePath, status }>}
|
|
496
|
+
*/
|
|
497
|
+
async writeFile(id, remotePath, content) {
|
|
498
|
+
const sftp = await this.openSFTP(id);
|
|
499
|
+
return new Promise((resolve, reject) => {
|
|
500
|
+
const stream = sftp.createWriteStream(remotePath);
|
|
501
|
+
stream.on("close", () => resolve({ remotePath, status: "WRITTEN" }));
|
|
502
|
+
stream.on("error", reject);
|
|
503
|
+
stream.end(content, "utf8");
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Delete a file on the remote host.
|
|
509
|
+
* @param {string} id
|
|
510
|
+
* @param {string} remotePath
|
|
511
|
+
* @returns {Promise<{ remotePath, status }>}
|
|
512
|
+
*/
|
|
513
|
+
async deleteFile(id, remotePath) {
|
|
514
|
+
const sftp = await this.openSFTP(id);
|
|
515
|
+
return new Promise((resolve, reject) => {
|
|
516
|
+
sftp.unlink(remotePath, err => {
|
|
517
|
+
if (err) return reject(err);
|
|
518
|
+
resolve({ remotePath, status: "DELETED" });
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Get the stat of a remote path.
|
|
525
|
+
* @param {string} id
|
|
526
|
+
* @param {string} remotePath
|
|
527
|
+
* @returns {Promise<object>}
|
|
528
|
+
*/
|
|
529
|
+
async stat(id, remotePath) {
|
|
530
|
+
const sftp = await this.openSFTP(id);
|
|
531
|
+
return new Promise((resolve, reject) => {
|
|
532
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
533
|
+
if (err) return reject(err);
|
|
534
|
+
resolve({
|
|
535
|
+
size: stats.size,
|
|
536
|
+
mode: stats.mode,
|
|
537
|
+
uid: stats.uid,
|
|
538
|
+
gid: stats.gid,
|
|
539
|
+
accessed: new Date(stats.atime * 1000).toISOString(),
|
|
540
|
+
modified: new Date(stats.mtime * 1000).toISOString(),
|
|
541
|
+
isDirectory: stats.isDirectory(),
|
|
542
|
+
isFile: stats.isFile(),
|
|
543
|
+
isSymlink: stats.isSymbolicLink(),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Rename/move a file on the remote host.
|
|
551
|
+
* @param {string} id
|
|
552
|
+
* @param {string} remoteSrc
|
|
553
|
+
* @param {string} remoteDest
|
|
554
|
+
* @returns {Promise<{ status }>}
|
|
555
|
+
*/
|
|
556
|
+
async rename(id, remoteSrc, remoteDest) {
|
|
557
|
+
const sftp = await this.openSFTP(id);
|
|
558
|
+
return new Promise((resolve, reject) => {
|
|
559
|
+
sftp.rename(remoteSrc, remoteDest, err => {
|
|
560
|
+
if (err) return reject(err);
|
|
561
|
+
resolve({ status: "RENAMED", from: remoteSrc, to: remoteDest });
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Create a directory on the remote host.
|
|
568
|
+
* @param {string} id
|
|
569
|
+
* @param {string} remotePath
|
|
570
|
+
* @param {boolean} [recursive=true]
|
|
571
|
+
* @returns {Promise<{ remotePath, status }>}
|
|
572
|
+
*/
|
|
573
|
+
async mkdir(id, remotePath, recursive = true) {
|
|
574
|
+
if (recursive) {
|
|
575
|
+
await this.exec(id, `mkdir -p ${remotePath}`);
|
|
576
|
+
return { remotePath, status: "CREATED" };
|
|
577
|
+
}
|
|
578
|
+
const sftp = await this.openSFTP(id);
|
|
579
|
+
return new Promise((resolve, reject) => {
|
|
580
|
+
sftp.mkdir(remotePath, err => {
|
|
581
|
+
if (err) return reject(err);
|
|
582
|
+
resolve({ remotePath, status: "CREATED" });
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
588
|
+
// TUNNELING & PORT FORWARDING
|
|
589
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Open a local port forward (ssh -L).
|
|
593
|
+
* Traffic to localhost:localPort is forwarded to remoteHost:remotePort via the SSH server.
|
|
594
|
+
*
|
|
595
|
+
* @param {string} id - Connection ID.
|
|
596
|
+
* @param {object} options
|
|
597
|
+
* @param {number} options.localPort
|
|
598
|
+
* @param {string} options.remoteHost
|
|
599
|
+
* @param {number} options.remotePort
|
|
600
|
+
* @param {string} [options.bindAddress="127.0.0.1"]
|
|
601
|
+
* @returns {Promise<{ tunnelId, localPort, remoteHost, remotePort, server }>}
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* await kitSSH.localForward(id, { localPort: 5432, remoteHost: "db.internal", remotePort: 5432 });
|
|
605
|
+
*/
|
|
606
|
+
localForward(id, { localPort, remoteHost, remotePort, bindAddress = "127.0.0.1" }) {
|
|
607
|
+
const c = _getConn(id);
|
|
608
|
+
const tunnelId = `tunnel-local-${localPort}`;
|
|
609
|
+
|
|
610
|
+
return new Promise((resolve, reject) => {
|
|
611
|
+
const server = net.createServer(sock => {
|
|
612
|
+
c.client.forwardOut(bindAddress, localPort, remoteHost, remotePort, (err, stream) => {
|
|
613
|
+
if (err) { sock.destroy(); return; }
|
|
614
|
+
sock.pipe(stream).pipe(sock);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
server.listen(localPort, bindAddress, () => {
|
|
619
|
+
c.tunnels = c.tunnels || [];
|
|
620
|
+
c.tunnels.push({ tunnelId, type: "local", localPort, remoteHost, remotePort, server });
|
|
621
|
+
resolve({ tunnelId, localPort, remoteHost, remotePort, status: "ACTIVE" });
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
server.on("error", reject);
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Open a remote port forward (ssh -R).
|
|
630
|
+
* Traffic to remotePort on the SSH server is forwarded to localHost:localPort.
|
|
631
|
+
*
|
|
632
|
+
* @param {string} id
|
|
633
|
+
* @param {object} options
|
|
634
|
+
* @param {number} options.remotePort
|
|
635
|
+
* @param {string} options.localHost
|
|
636
|
+
* @param {number} options.localPort
|
|
637
|
+
* @returns {Promise<{ tunnelId, remotePort, localHost, localPort }>}
|
|
638
|
+
*/
|
|
639
|
+
remoteForward(id, { remotePort, localHost, localPort }) {
|
|
640
|
+
const c = _getConn(id);
|
|
641
|
+
const tunnelId = `tunnel-remote-${remotePort}`;
|
|
642
|
+
|
|
643
|
+
return new Promise((resolve, reject) => {
|
|
644
|
+
c.client.forwardIn("0.0.0.0", remotePort, (err) => {
|
|
645
|
+
if (err) return reject(err);
|
|
646
|
+
|
|
647
|
+
c.client.on("tcp connection", (info, accept) => {
|
|
648
|
+
if (info.destPort !== remotePort) return;
|
|
649
|
+
const stream = accept();
|
|
650
|
+
const sock = net.connect(localPort, localHost);
|
|
651
|
+
stream.pipe(sock).pipe(stream);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
c.tunnels = c.tunnels || [];
|
|
655
|
+
c.tunnels.push({ tunnelId, type: "remote", remotePort, localHost, localPort });
|
|
656
|
+
resolve({ tunnelId, remotePort, localHost, localPort, status: "ACTIVE" });
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Open a dynamic SOCKS5 proxy tunnel (ssh -D).
|
|
663
|
+
* @param {string} id
|
|
664
|
+
* @param {number} localPort - Local SOCKS5 proxy port.
|
|
665
|
+
* @returns {Promise<{ tunnelId, localPort, type }>}
|
|
666
|
+
*/
|
|
667
|
+
dynamicForward(id, localPort) {
|
|
668
|
+
const c = _getConn(id);
|
|
669
|
+
const tunnelId = `tunnel-dynamic-${localPort}`;
|
|
670
|
+
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
const server = net.createServer(sock => {
|
|
673
|
+
// Minimal SOCKS5 handshake
|
|
674
|
+
sock.once("data", data => {
|
|
675
|
+
if (data[0] !== 0x05) { sock.destroy(); return; }
|
|
676
|
+
sock.write(Buffer.from([0x05, 0x00])); // no auth
|
|
677
|
+
|
|
678
|
+
sock.once("data", req => {
|
|
679
|
+
if (req[1] !== 0x01) { sock.destroy(); return; } // CONNECT only
|
|
680
|
+
const hostLen = req[4];
|
|
681
|
+
const host = req.slice(5, 5 + hostLen).toString();
|
|
682
|
+
const port = req.readUInt16BE(5 + hostLen);
|
|
683
|
+
|
|
684
|
+
c.client.forwardOut("127.0.0.1", localPort, host, port, (err, stream) => {
|
|
685
|
+
if (err) { sock.destroy(); return; }
|
|
686
|
+
const resp = Buffer.alloc(10);
|
|
687
|
+
resp[0] = 0x05; resp[1] = 0x00; resp[3] = 0x01;
|
|
688
|
+
sock.write(resp);
|
|
689
|
+
sock.pipe(stream).pipe(sock);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
server.listen(localPort, "127.0.0.1", () => {
|
|
696
|
+
c.tunnels = c.tunnels || [];
|
|
697
|
+
c.tunnels.push({ tunnelId, type: "dynamic-socks5", localPort, server });
|
|
698
|
+
resolve({ tunnelId, localPort, type: "SOCKS5", status: "ACTIVE" });
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
server.on("error", reject);
|
|
702
|
+
});
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Close a specific tunnel by its tunnel ID.
|
|
707
|
+
* @param {string} id - Connection ID.
|
|
708
|
+
* @param {string} tunnelId
|
|
709
|
+
* @returns {{ tunnelId, status }}
|
|
710
|
+
*/
|
|
711
|
+
closeTunnel(id, tunnelId) {
|
|
712
|
+
const c = _getConn(id);
|
|
713
|
+
const idx = (c.tunnels || []).findIndex(t => t.tunnelId === tunnelId);
|
|
714
|
+
if (idx === -1) throw new Error(`Tunnel "${tunnelId}" not found.`);
|
|
715
|
+
const tunnel = c.tunnels[idx];
|
|
716
|
+
if (tunnel.server) tunnel.server.close();
|
|
717
|
+
c.tunnels.splice(idx, 1);
|
|
718
|
+
return { tunnelId, status: "CLOSED" };
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* List all active tunnels on a connection.
|
|
723
|
+
* @param {string} id
|
|
724
|
+
* @returns {object[]}
|
|
725
|
+
*/
|
|
726
|
+
listTunnels(id) {
|
|
727
|
+
const c = _getConn(id);
|
|
728
|
+
return (c.tunnels || []).map(({ server, ...rest }) => rest);
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
732
|
+
// KEY MANAGEMENT
|
|
733
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Generate a new SSH key pair.
|
|
737
|
+
* @param {object} [options]
|
|
738
|
+
* @param {'ed25519'|'rsa'|'ecdsa'} [options.type='ed25519']
|
|
739
|
+
* @param {number} [options.bits=4096] - RSA bits.
|
|
740
|
+
* @param {string} [options.comment='']
|
|
741
|
+
* @param {string} [options.passphrase='']
|
|
742
|
+
* @param {string} [options.outputPath] - Base path (no extension). Defaults to ~/.ssh/id_<type>.
|
|
743
|
+
* @returns {{ privateKeyPath, publicKeyPath, fingerprint, type }}
|
|
744
|
+
*
|
|
745
|
+
* @example
|
|
746
|
+
* kitSSH.generateKey({ type: "ed25519", comment: "deploy@prod" });
|
|
747
|
+
*/
|
|
748
|
+
generateKey(options = {}) {
|
|
749
|
+
const type = options.type || "ed25519";
|
|
750
|
+
const bits = options.bits || 4096;
|
|
751
|
+
const comment = options.comment || "";
|
|
752
|
+
const passphrase = options.passphrase || "";
|
|
753
|
+
const basePath = options.outputPath || path.join(_sshDir(), `id_${type}_${Date.now()}`);
|
|
754
|
+
const privPath = basePath;
|
|
755
|
+
const pubPath = `${basePath}.pub`;
|
|
756
|
+
|
|
757
|
+
let cmd = `ssh-keygen -t ${type} -C "${comment}" -f "${privPath}" -N "${passphrase}"`;
|
|
758
|
+
if (type === "rsa") cmd += ` -b ${bits}`;
|
|
759
|
+
|
|
760
|
+
execSync(cmd, { stdio: "pipe" });
|
|
761
|
+
|
|
762
|
+
const pub = fs.readFileSync(pubPath, "utf8").trim();
|
|
763
|
+
const fingerprint = execSync(`ssh-keygen -lf "${pubPath}"`, { encoding: "utf8" }).trim();
|
|
764
|
+
|
|
765
|
+
return { privateKeyPath: privPath, publicKeyPath: pubPath, fingerprint, type, comment };
|
|
766
|
+
},
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Add a public key to the remote host's authorized_keys for a user.
|
|
770
|
+
* @param {string} id - Connection ID.
|
|
771
|
+
* @param {string} publicKey - Path to .pub file or raw public key string.
|
|
772
|
+
* @param {string} [remoteUser] - Defaults to the connected user.
|
|
773
|
+
* @returns {Promise<{ status }>}
|
|
774
|
+
*/
|
|
775
|
+
async authorizeKey(id, publicKey, remoteUser) {
|
|
776
|
+
let key = publicKey;
|
|
777
|
+
if (!key.startsWith("ssh-") && !key.startsWith("ecdsa-")) {
|
|
778
|
+
key = fs.readFileSync(publicKey.replace(/^~/, os.homedir()), "utf8").trim();
|
|
779
|
+
}
|
|
780
|
+
const user = remoteUser || _getConn(id).config.username;
|
|
781
|
+
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
|
|
782
|
+
await this.exec(id, cmd);
|
|
783
|
+
return { status: "KEY AUTHORIZED", user, fingerprint: _fingerprint(key) };
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Remove a public key from the remote host's authorized_keys.
|
|
788
|
+
* @param {string} id
|
|
789
|
+
* @param {string} publicKey - Path to .pub file or raw public key string.
|
|
790
|
+
* @returns {Promise<{ status }>}
|
|
791
|
+
*/
|
|
792
|
+
async revokeKey(id, publicKey) {
|
|
793
|
+
let key = publicKey;
|
|
794
|
+
if (!key.startsWith("ssh-") && !key.startsWith("ecdsa-")) {
|
|
795
|
+
key = fs.readFileSync(publicKey.replace(/^~/, os.homedir()), "utf8").trim();
|
|
796
|
+
}
|
|
797
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
798
|
+
await this.exec(id, `sed -i '/${escaped}/d' ~/.ssh/authorized_keys`);
|
|
799
|
+
return { status: "KEY REVOKED" };
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* List all public keys in the remote host's authorized_keys.
|
|
804
|
+
* @param {string} id
|
|
805
|
+
* @returns {Promise<string[]>}
|
|
806
|
+
*/
|
|
807
|
+
async listAuthorizedKeys(id) {
|
|
808
|
+
const { stdout } = await this.exec(id, "cat ~/.ssh/authorized_keys 2>/dev/null || echo ''");
|
|
809
|
+
return stdout.trim().split("\n").filter(Boolean);
|
|
810
|
+
},
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Add a remote host's fingerprint to the local known_hosts file.
|
|
814
|
+
* @param {string} host
|
|
815
|
+
* @param {number} [port=22]
|
|
816
|
+
* @returns {{ host, fingerprint, status }}
|
|
817
|
+
*/
|
|
818
|
+
addKnownHost(host, port = 22) {
|
|
819
|
+
const result = execSync(`ssh-keyscan -p ${port} ${host} 2>/dev/null`, { encoding: "utf8" }).trim();
|
|
820
|
+
const knownHostsPath = path.join(_sshDir(), "known_hosts");
|
|
821
|
+
fs.mkdirSync(_sshDir(), { recursive: true });
|
|
822
|
+
fs.appendFileSync(knownHostsPath, `\n${result}\n`);
|
|
823
|
+
const fingerprint = _fingerprint(result);
|
|
824
|
+
_knownHosts.set(`${host}:${port}`, { fingerprint, addedAt: new Date().toISOString() });
|
|
825
|
+
return { host, port, fingerprint, status: "ADDED TO KNOWN_HOSTS" };
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Remove a host from the local known_hosts file.
|
|
830
|
+
* @param {string} host
|
|
831
|
+
* @param {number} [port=22]
|
|
832
|
+
* @returns {{ status }}
|
|
833
|
+
*/
|
|
834
|
+
removeKnownHost(host, port = 22) {
|
|
835
|
+
execSync(`ssh-keygen -R "[${host}]:${port}" 2>/dev/null || ssh-keygen -R "${host}" 2>/dev/null`, { stdio: "pipe" });
|
|
836
|
+
_knownHosts.delete(`${host}:${port}`);
|
|
837
|
+
return { host, port, status: "REMOVED FROM KNOWN_HOSTS" };
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Check if a host's fingerprint is already trusted in known_hosts.
|
|
842
|
+
* @param {string} host
|
|
843
|
+
* @param {number} [port=22]
|
|
844
|
+
* @returns {{ trusted: boolean, fingerprint?: string }}
|
|
845
|
+
*/
|
|
846
|
+
checkKnownHost(host, port = 22) {
|
|
847
|
+
try {
|
|
848
|
+
const result = execSync(`ssh-keygen -F "[${host}]:${port}" 2>/dev/null`, { encoding: "utf8" });
|
|
849
|
+
return { trusted: result.trim().length > 0, fingerprint: _fingerprint(result) };
|
|
850
|
+
} catch {
|
|
851
|
+
return { trusted: false };
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Get the fingerprint of a remote host without connecting.
|
|
857
|
+
* @param {string} host
|
|
858
|
+
* @param {number} [port=22]
|
|
859
|
+
* @returns {{ host, port, fingerprint }}
|
|
860
|
+
*/
|
|
861
|
+
getHostFingerprint(host, port = 22) {
|
|
862
|
+
const raw = execSync(`ssh-keyscan -p ${port} ${host} 2>/dev/null`, { encoding: "utf8" }).trim();
|
|
863
|
+
return { host, port, fingerprint: _fingerprint(raw), raw };
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Change the passphrase on an existing private key.
|
|
868
|
+
* @param {string} privateKeyPath
|
|
869
|
+
* @param {string} oldPassphrase
|
|
870
|
+
* @param {string} newPassphrase
|
|
871
|
+
* @returns {{ status }}
|
|
872
|
+
*/
|
|
873
|
+
changePassphrase(privateKeyPath, oldPassphrase, newPassphrase) {
|
|
874
|
+
const expanded = privateKeyPath.replace(/^~/, os.homedir());
|
|
875
|
+
execSync(`ssh-keygen -p -P "${oldPassphrase}" -N "${newPassphrase}" -f "${expanded}"`, { stdio: "pipe" });
|
|
876
|
+
return { status: "PASSPHRASE CHANGED", path: expanded };
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Get the fingerprint of a local key file.
|
|
881
|
+
* @param {string} keyPath - Path to public or private key.
|
|
882
|
+
* @returns {{ fingerprint, type }}
|
|
883
|
+
*/
|
|
884
|
+
getKeyFingerprint(keyPath) {
|
|
885
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
886
|
+
const result = execSync(`ssh-keygen -lf "${expanded}"`, { encoding: "utf8" }).trim();
|
|
887
|
+
const [bits, fingerprint, ...rest] = result.split(" ");
|
|
888
|
+
return { bits: parseInt(bits), fingerprint, comment: rest.slice(0, -1).join(" "), type: rest[rest.length - 1] };
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
892
|
+
// SSH AGENT
|
|
893
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Add a key to the local SSH agent.
|
|
897
|
+
* @param {string} keyPath
|
|
898
|
+
* @param {string} [passphrase]
|
|
899
|
+
* @returns {{ status }}
|
|
900
|
+
*/
|
|
901
|
+
agentAddKey(keyPath, passphrase) {
|
|
902
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
903
|
+
if (passphrase) {
|
|
904
|
+
execSync(`ssh-add "${expanded}"`, {
|
|
905
|
+
env: { ...process.env, SSH_ASKPASS_REQUIRE: "never" },
|
|
906
|
+
input: passphrase,
|
|
907
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
908
|
+
});
|
|
909
|
+
} else {
|
|
910
|
+
execSync(`ssh-add "${expanded}"`, { stdio: "pipe" });
|
|
911
|
+
}
|
|
912
|
+
return { status: "KEY ADDED TO AGENT", path: expanded };
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* List all keys currently loaded in the SSH agent.
|
|
917
|
+
* @returns {string[]}
|
|
918
|
+
*/
|
|
919
|
+
agentListKeys() {
|
|
920
|
+
try {
|
|
921
|
+
const result = execSync("ssh-add -l", { encoding: "utf8" }).trim();
|
|
922
|
+
return result.split("\n").filter(Boolean);
|
|
923
|
+
} catch {
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Remove a key from the SSH agent.
|
|
930
|
+
* @param {string} keyPath
|
|
931
|
+
* @returns {{ status }}
|
|
932
|
+
*/
|
|
933
|
+
agentRemoveKey(keyPath) {
|
|
934
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
935
|
+
execSync(`ssh-add -d "${expanded}"`, { stdio: "pipe" });
|
|
936
|
+
return { status: "KEY REMOVED FROM AGENT", path: expanded };
|
|
937
|
+
},
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Remove all keys from the SSH agent.
|
|
941
|
+
* @returns {{ status }}
|
|
942
|
+
*/
|
|
943
|
+
agentClearKeys() {
|
|
944
|
+
execSync("ssh-add -D", { stdio: "pipe" });
|
|
945
|
+
return { status: "ALL KEYS REMOVED FROM AGENT" };
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
949
|
+
// CONNECTION POOLING / MULTIPLEXING
|
|
950
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Get or create a pooled connection.
|
|
954
|
+
* Reuses an existing alive connection to the same host/user/port.
|
|
955
|
+
* @param {object} config - Same as connect().
|
|
956
|
+
* @returns {Promise<{ id, reused: boolean }>}
|
|
957
|
+
*/
|
|
958
|
+
async pooledConnect(config) {
|
|
959
|
+
const key = _poolKey(config);
|
|
960
|
+
const existing = (_pool.get(key) || []).find(id => _connections.has(id));
|
|
961
|
+
if (existing) return { id: existing, reused: true };
|
|
962
|
+
|
|
963
|
+
const { id } = await this.connect(config);
|
|
964
|
+
const poolList = _pool.get(key) || [];
|
|
965
|
+
poolList.push(id);
|
|
966
|
+
_pool.set(key, poolList);
|
|
967
|
+
return { id, reused: false };
|
|
968
|
+
},
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Release a pooled connection (but keep it open for reuse).
|
|
972
|
+
* @param {string} id
|
|
973
|
+
* @returns {{ id, status }}
|
|
974
|
+
*/
|
|
975
|
+
poolRelease(id) {
|
|
976
|
+
return { id, status: "RETURNED TO POOL" };
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Drain the pool — close all pooled connections for a host.
|
|
981
|
+
* @param {string} host
|
|
982
|
+
* @param {string} username
|
|
983
|
+
* @param {number} [port=22]
|
|
984
|
+
* @returns {{ drained: number }}
|
|
985
|
+
*/
|
|
986
|
+
poolDrain(host, username, port = 22) {
|
|
987
|
+
const key = `${username}@${host}:${port}`;
|
|
988
|
+
const ids = _pool.get(key) || [];
|
|
989
|
+
ids.forEach(id => this.disconnect(id));
|
|
990
|
+
_pool.delete(key);
|
|
991
|
+
return { drained: ids.length };
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
995
|
+
// JUMP HOSTS / PROXY
|
|
996
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Connect to a target host via one or more jump hosts (ProxyJump chain).
|
|
1000
|
+
* @param {object[]} hops - Array of connection configs, last entry is the target.
|
|
1001
|
+
* @returns {Promise<{ id: string, hops: number }>}
|
|
1002
|
+
*
|
|
1003
|
+
* @example
|
|
1004
|
+
* const { id } = await kitSSH.jumpConnect([
|
|
1005
|
+
* { host: "bastion.example.com", username: "admin", privateKey: "~/.ssh/id_ed25519" },
|
|
1006
|
+
* { host: "internal.db.local", username: "postgres", privateKey: "~/.ssh/id_ed25519" },
|
|
1007
|
+
* ]);
|
|
1008
|
+
*/
|
|
1009
|
+
async jumpConnect(hops) {
|
|
1010
|
+
if (hops.length === 0) throw new Error("At least one hop required.");
|
|
1011
|
+
let prevId = null;
|
|
1012
|
+
for (let i = 0; i < hops.length; i++) {
|
|
1013
|
+
const config = { ...hops[i] };
|
|
1014
|
+
if (prevId) config.jumpHost = prevId;
|
|
1015
|
+
const { id } = await this.connect(config);
|
|
1016
|
+
prevId = id;
|
|
1017
|
+
}
|
|
1018
|
+
return { id: prevId, hops: hops.length };
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1022
|
+
// CONFIG FILE
|
|
1023
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Parse the local ~/.ssh/config file and return entries as objects.
|
|
1027
|
+
* @returns {object[]}
|
|
1028
|
+
*/
|
|
1029
|
+
parseConfig() {
|
|
1030
|
+
const configPath = path.join(_sshDir(), "config");
|
|
1031
|
+
if (!fs.existsSync(configPath)) return [];
|
|
1032
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
1033
|
+
const entries = [];
|
|
1034
|
+
let current = null;
|
|
1035
|
+
for (const line of text.split("\n")) {
|
|
1036
|
+
const trimmed = line.trim();
|
|
1037
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1038
|
+
const [key, ...rest] = trimmed.split(/\s+/);
|
|
1039
|
+
const value = rest.join(" ");
|
|
1040
|
+
if (key.toLowerCase() === "host") {
|
|
1041
|
+
if (current) entries.push(current);
|
|
1042
|
+
current = { Host: value };
|
|
1043
|
+
} else if (current) {
|
|
1044
|
+
current[key] = value;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (current) entries.push(current);
|
|
1048
|
+
return entries;
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Add a host entry to ~/.ssh/config.
|
|
1053
|
+
* @param {object} entry - e.g. { Host: "myserver", HostName: "1.2.3.4", User: "admin", IdentityFile: "~/.ssh/id_ed25519" }
|
|
1054
|
+
* @returns {{ status, configPath }}
|
|
1055
|
+
*/
|
|
1056
|
+
addConfigEntry(entry) {
|
|
1057
|
+
const configPath = path.join(_sshDir(), "config");
|
|
1058
|
+
fs.mkdirSync(_sshDir(), { recursive: true });
|
|
1059
|
+
const lines = ["", `Host ${entry.Host}`];
|
|
1060
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
1061
|
+
if (k !== "Host") lines.push(` ${k} ${v}`);
|
|
1062
|
+
}
|
|
1063
|
+
fs.appendFileSync(configPath, lines.join("\n") + "\n");
|
|
1064
|
+
return { status: "ENTRY ADDED", configPath };
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Remove a host entry from ~/.ssh/config by Host pattern.
|
|
1069
|
+
* @param {string} hostPattern
|
|
1070
|
+
* @returns {{ status }}
|
|
1071
|
+
*/
|
|
1072
|
+
removeConfigEntry(hostPattern) {
|
|
1073
|
+
const configPath = path.join(_sshDir(), "config");
|
|
1074
|
+
if (!fs.existsSync(configPath)) return { status: "CONFIG NOT FOUND" };
|
|
1075
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
1076
|
+
const blocks = text.split(/(?=^Host )/m);
|
|
1077
|
+
const filtered = blocks.filter(b => !b.startsWith(`Host ${hostPattern}`));
|
|
1078
|
+
fs.writeFileSync(configPath, filtered.join(""));
|
|
1079
|
+
return { status: "ENTRY REMOVED", hostPattern };
|
|
1080
|
+
},
|
|
1081
|
+
|
|
1082
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1083
|
+
// DIAGNOSTICS & UTILITIES
|
|
1084
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Check if a remote port is reachable from the SSH server.
|
|
1088
|
+
* @param {string} id
|
|
1089
|
+
* @param {string} host
|
|
1090
|
+
* @param {number} port
|
|
1091
|
+
* @param {number} [timeoutSeconds=5]
|
|
1092
|
+
* @returns {Promise<{ reachable: boolean, host, port }>}
|
|
1093
|
+
*/
|
|
1094
|
+
async checkPort(id, host, port, timeoutSeconds = 5) {
|
|
1095
|
+
const { stdout } = await this.exec(
|
|
1096
|
+
id,
|
|
1097
|
+
`(echo >/dev/tcp/${host}/${port}) &>/dev/null && echo "open" || echo "closed"`,
|
|
1098
|
+
);
|
|
1099
|
+
return { reachable: stdout.trim() === "open", host, port };
|
|
1100
|
+
},
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Get system information from the remote host.
|
|
1104
|
+
* @param {string} id
|
|
1105
|
+
* @returns {Promise<object>}
|
|
1106
|
+
*/
|
|
1107
|
+
async systemInfo(id) {
|
|
1108
|
+
const [os_, hostname, uptime, mem, disk, cpu] = await Promise.all([
|
|
1109
|
+
this.exec(id, "uname -a"),
|
|
1110
|
+
this.exec(id, "hostname"),
|
|
1111
|
+
this.exec(id, "uptime -p 2>/dev/null || uptime"),
|
|
1112
|
+
this.exec(id, "free -h 2>/dev/null || vm_stat"),
|
|
1113
|
+
this.exec(id, "df -h /"),
|
|
1114
|
+
this.exec(id, "nproc 2>/dev/null || sysctl -n hw.ncpu"),
|
|
1115
|
+
]);
|
|
1116
|
+
return {
|
|
1117
|
+
os: os_.stdout.trim(),
|
|
1118
|
+
hostname: hostname.stdout.trim(),
|
|
1119
|
+
uptime: uptime.stdout.trim(),
|
|
1120
|
+
memory: mem.stdout.trim(),
|
|
1121
|
+
disk: disk.stdout.trim(),
|
|
1122
|
+
cpuCores: cpu.stdout.trim(),
|
|
1123
|
+
};
|
|
1124
|
+
},
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Measure round-trip latency to the SSH server.
|
|
1128
|
+
* @param {string} id
|
|
1129
|
+
* @param {number} [samples=5]
|
|
1130
|
+
* @returns {Promise<{ avgMs: number, minMs: number, maxMs: number, samples: number[] }>}
|
|
1131
|
+
*/
|
|
1132
|
+
async ping(id, samples = 5) {
|
|
1133
|
+
const times = [];
|
|
1134
|
+
for (let i = 0; i < samples; i++) {
|
|
1135
|
+
const start = Date.now();
|
|
1136
|
+
await this.exec(id, "echo 1");
|
|
1137
|
+
times.push(Date.now() - start);
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
avgMs: Math.round(times.reduce((a, b) => a + b, 0) / times.length),
|
|
1141
|
+
minMs: Math.min(...times),
|
|
1142
|
+
maxMs: Math.max(...times),
|
|
1143
|
+
samples: times,
|
|
1144
|
+
};
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Test connectivity to a host without establishing a full session.
|
|
1149
|
+
* @param {string} host
|
|
1150
|
+
* @param {number} [port=22]
|
|
1151
|
+
* @param {number} [timeoutMs=5000]
|
|
1152
|
+
* @returns {Promise<{ reachable: boolean, latencyMs?: number }>}
|
|
1153
|
+
*/
|
|
1154
|
+
testConnectivity(host, port = 22, timeoutMs = 5000) {
|
|
1155
|
+
return new Promise(resolve => {
|
|
1156
|
+
const start = Date.now();
|
|
1157
|
+
const sock = net.connect(port, host);
|
|
1158
|
+
sock.setTimeout(timeoutMs);
|
|
1159
|
+
sock.on("connect", () => {
|
|
1160
|
+
const latencyMs = Date.now() - start;
|
|
1161
|
+
sock.destroy();
|
|
1162
|
+
resolve({ reachable: true, latencyMs });
|
|
1163
|
+
});
|
|
1164
|
+
sock.on("error", () => resolve({ reachable: false }));
|
|
1165
|
+
sock.on("timeout", () => { sock.destroy(); resolve({ reachable: false }); });
|
|
1166
|
+
});
|
|
1167
|
+
},
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Get the remote host's SSH server version banner.
|
|
1171
|
+
* @param {string} host
|
|
1172
|
+
* @param {number} [port=22]
|
|
1173
|
+
* @returns {Promise<{ banner: string }>}
|
|
1174
|
+
*/
|
|
1175
|
+
getServerBanner(host, port = 22) {
|
|
1176
|
+
return new Promise((resolve, reject) => {
|
|
1177
|
+
const sock = net.connect(port, host);
|
|
1178
|
+
let banner = "";
|
|
1179
|
+
sock.on("data", data => {
|
|
1180
|
+
banner += data.toString();
|
|
1181
|
+
if (banner.includes("\n")) {
|
|
1182
|
+
sock.destroy();
|
|
1183
|
+
resolve({ host, port, banner: banner.split("\n")[0].trim() });
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
sock.on("error", reject);
|
|
1187
|
+
setTimeout(() => { sock.destroy(); reject(new Error("Banner timeout")); }, 5000);
|
|
1188
|
+
});
|
|
1189
|
+
},
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Copy a file between two remote hosts (remote-to-remote via local bounce).
|
|
1193
|
+
* @param {string} srcId - Source connection ID.
|
|
1194
|
+
* @param {string} srcPath
|
|
1195
|
+
* @param {string} dstId - Destination connection ID.
|
|
1196
|
+
* @param {string} dstPath
|
|
1197
|
+
* @returns {Promise<{ status }>}
|
|
1198
|
+
*/
|
|
1199
|
+
async remoteCopy(srcId, srcPath, dstId, dstPath) {
|
|
1200
|
+
const tmpPath = path.join(os.tmpdir(), `kitSSH_bounce_${Date.now()}`);
|
|
1201
|
+
await this.download(srcId, srcPath, tmpPath);
|
|
1202
|
+
await this.upload(dstId, tmpPath, dstPath);
|
|
1203
|
+
fs.unlinkSync(tmpPath);
|
|
1204
|
+
return { status: "COPIED", from: `${srcId}:${srcPath}`, to: `${dstId}:${dstPath}` };
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Watch a remote file for changes and call a callback when it changes.
|
|
1209
|
+
* Uses polling.
|
|
1210
|
+
* @param {string} id
|
|
1211
|
+
* @param {string} remotePath
|
|
1212
|
+
* @param {function} onChange - Called with new content string.
|
|
1213
|
+
* @param {number} [intervalMs=2000]
|
|
1214
|
+
* @returns {{ stop: function }} - Call stop() to end watching.
|
|
1215
|
+
*/
|
|
1216
|
+
watchFile(id, remotePath, onChange, intervalMs = 2000) {
|
|
1217
|
+
let last = null;
|
|
1218
|
+
const timer = setInterval(async () => {
|
|
1219
|
+
try {
|
|
1220
|
+
const content = await this.readFile(id, remotePath);
|
|
1221
|
+
if (content !== last) { last = content; onChange(content); }
|
|
1222
|
+
} catch { /* file may not exist yet */ }
|
|
1223
|
+
}, intervalMs);
|
|
1224
|
+
return { stop: () => clearInterval(timer) };
|
|
1225
|
+
},
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Tail a remote log file, streaming new lines to a callback.
|
|
1229
|
+
* @param {string} id
|
|
1230
|
+
* @param {string} remotePath
|
|
1231
|
+
* @param {function} onLine
|
|
1232
|
+
* @param {number} [lines=10] - Initial lines to show.
|
|
1233
|
+
* @returns {Promise<{ stop: function }>}
|
|
1234
|
+
*/
|
|
1235
|
+
async tailFile(id, remotePath, onLine, lines = 10) {
|
|
1236
|
+
const stream = await this.shell(id);
|
|
1237
|
+
stream.write(`tail -n ${lines} -f ${remotePath}\n`);
|
|
1238
|
+
let buf = "";
|
|
1239
|
+
stream.on("data", data => {
|
|
1240
|
+
buf += data.toString();
|
|
1241
|
+
const parts = buf.split("\n");
|
|
1242
|
+
buf = parts.pop();
|
|
1243
|
+
parts.forEach(line => onLine(line));
|
|
1244
|
+
});
|
|
1245
|
+
return { stop: () => stream.end("exit\n") };
|
|
1246
|
+
},
|
|
1247
|
+
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// ── Internal helpers (outside kitdef for cleanliness) ────────────────────────
|
|
1252
|
+
|
|
1253
|
+
function _finishConnect(client, sshConfig, id, originalConfig, resolve, reject) {
|
|
1254
|
+
client.on("ready", () => {
|
|
1255
|
+
_connections.set(id, {
|
|
1256
|
+
client,
|
|
1257
|
+
config: originalConfig,
|
|
1258
|
+
tunnels: [],
|
|
1259
|
+
sftp: null,
|
|
1260
|
+
openedAt: new Date().toISOString(),
|
|
1261
|
+
});
|
|
1262
|
+
resolve({
|
|
1263
|
+
id,
|
|
1264
|
+
host: originalConfig.host,
|
|
1265
|
+
port: originalConfig.port || 22,
|
|
1266
|
+
username: originalConfig.username,
|
|
1267
|
+
status: "CONNECTED",
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
client.on("error", reject);
|
|
1271
|
+
client.connect(sshConfig);
|
|
1272
|
+
}
|