ssh-client-wrapper 2.17.1 → 3.0.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 +25 -1
- package/lib/agent.js +524 -0
- package/lib/index.d.ts +15 -2
- package/lib/index.js +24 -1
- package/lib/sshExec.js +34 -1
- package/lib/util.js +20 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[](https://badge.fury.io/js/ssh-client-wrapper)
|
|
2
2
|
[](https://snyk.io/test/github/so5/ssh-client-wrapper)
|
|
3
3
|
[](https://coveralls.io/github/so5/ssh-client-wrapper?branch=main)
|
|
4
|
-
[](https://sonarcloud.io/summary/new_code?id=so5_ssh-client-wrapper)
|
|
5
5
|
# ssh-client-wrapper
|
|
6
6
|
open ssh client wrapper library for nodejs
|
|
7
7
|
|
|
@@ -64,6 +64,9 @@ Creates a new SSH client instance.
|
|
|
64
64
|
* `retryDuration` (number, optional): Time (msec) to wait between each retry. Defaults to `1000`.
|
|
65
65
|
* `retryableExitCodes` (number[], optional): Additional rsync exit codes to treat as retryable for `.send()`/`.recv()`. See [Retry behavior for `.send()` / `.recv()`](#retry-behavior-for-send--recv) below.
|
|
66
66
|
* `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in retryable exit code list instead of adding to it.
|
|
67
|
+
* `useAgent` (boolean, optional): Authenticate key-based hosts through an ssh-agent instead of replaying the passphrase on every connection. Defaults to `true` when `keyFile` is set. Set `false` to force the legacy behavior. See [SSH agent authentication](#ssh-agent-authentication) below.
|
|
68
|
+
* `identityAgent` (string, optional): Path to an existing ssh-agent socket to authenticate through (emitted as `-oIdentityAgent`). When set, the wrapper uses that agent as-is and manages nothing.
|
|
69
|
+
* `agentKeyTTL` (number, optional): Lifetime in seconds applied to keys the wrapper adds to an agent (`ssh-add -t`). Defaults to `3600`.
|
|
67
70
|
* And more... see `lib/index.js` for all available options.
|
|
68
71
|
|
|
69
72
|
### `.exec(cmd, [timeout], [outputCallback], [rcfile], [prependCmd])`
|
|
@@ -178,3 +181,24 @@ Checks if a connection to the remote host can be established.
|
|
|
178
181
|
|
|
179
182
|
### `.disconnect()`
|
|
180
183
|
Closes the master SSH connection to the remote host.
|
|
184
|
+
|
|
185
|
+
### `.dispose()`
|
|
186
|
+
Removes this host's key from the wrapper-managed (or caller-supplied) ssh-agent and then closes the master SSH connection. It does **not** kill the shared agent process, since other hosts or processes may still be using it. Safe to call repeatedly.
|
|
187
|
+
|
|
188
|
+
* Returns: `Promise<void>`
|
|
189
|
+
|
|
190
|
+
### SSH agent authentication
|
|
191
|
+
|
|
192
|
+
When `keyFile` is set, key-based authentication goes through an **ssh-agent** by default (`useAgent: true`). The passphrase (if any) is consumed once to `ssh-add` the key; every subsequent connection and every master-connection rebuild after a `ControlPersist` expiry then authenticates non-interactively, so the wrapper never has to hold a replayable secret.
|
|
193
|
+
|
|
194
|
+
How the agent is chosen, per host, at connect time:
|
|
195
|
+
|
|
196
|
+
1. If `identityAgent` is set, that socket is used as-is.
|
|
197
|
+
2. Otherwise, if `SSH_AUTH_SOCK` is present in the environment, that agent is used (the key is `ssh-add`ed into it with a `-t` lifetime, so it does not linger there indefinitely).
|
|
198
|
+
3. Otherwise, on POSIX, the wrapper spawns one shared `ssh-agent` per user, at a fixed socket under `$XDG_RUNTIME_DIR` / `/run/user/<uid>` / `/tmp` (overridable with `SSH_CLIENT_WRAPPER_AGENT_DIR`). It is reused by later runs and is not killed on exit; keys expire from it via `agentKeyTTL` (default 1 h).
|
|
199
|
+
|
|
200
|
+
If no agent can be used, or `ssh-add` fails, the wrapper silently falls back to the legacy behavior of answering the passphrase prompt on each connection. Set `useAgent: false` to force that path.
|
|
201
|
+
|
|
202
|
+
**Keyless / browser agents:** with no `keyFile`, set `identityAgent` to the socket of an already-populated agent (for example [`bssh-agent`](https://github.com/so5/browser-ssh-agent), which keeps the private key in a browser tab) and the wrapper will authenticate through it.
|
|
203
|
+
|
|
204
|
+
**Windows:** an already-running agent (the OpenSSH Authentication Agent service, or a supplied `identityAgent`) is used, but the wrapper never spawns one; without a reachable agent it falls back to the legacy path.
|
package/lib/agent.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
|
|
7
|
+
import Debug from "debug";
|
|
8
|
+
import { spawn } from "node-pty-prebuilt-multiarch";
|
|
9
|
+
import { rePhPrompt } from "./util.js";
|
|
10
|
+
|
|
11
|
+
const debug = Debug("sshClientWrapper:debug:agent");
|
|
12
|
+
const debugVerbose = Debug("sshClientWrapper:verbose:agent");
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TTL = 3600;
|
|
15
|
+
const ADDKEY_TIMEOUT_SEC = 60;
|
|
16
|
+
const CHILD_TIMEOUT_MS = 15000;
|
|
17
|
+
const LOCK_TIMEOUT_MS = 15000;
|
|
18
|
+
const LOCK_STALE_MS = 60000;
|
|
19
|
+
const MAX_BAD_PASSPHRASE = 3;
|
|
20
|
+
const MAX_SOCKET_PATH = 103;
|
|
21
|
+
|
|
22
|
+
//sentinel candidate meaning "run ssh-add with the inherited environment and let
|
|
23
|
+
//OpenSSH use its default agent (e.g. the Windows OpenSSH Authentication Agent pipe)"
|
|
24
|
+
const AGENT_DEFAULT = "__scw_default_agent__";
|
|
25
|
+
|
|
26
|
+
//injection seam for tests
|
|
27
|
+
const _internal = {
|
|
28
|
+
spawn,
|
|
29
|
+
execFileP: promisify(execFile)
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let sharedSock = null;
|
|
33
|
+
let sharedInFlight = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* promisified sleep
|
|
37
|
+
* @param {number} ms - milliseconds to wait
|
|
38
|
+
* @returns {Promise<void>} - resolves after ms
|
|
39
|
+
*/
|
|
40
|
+
function sleep(ms) {
|
|
41
|
+
return setTimeoutPromise(ms);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* check if path points to a regular file
|
|
46
|
+
* @param {string} p - path to check
|
|
47
|
+
* @returns {boolean} - true if p is an existing regular file
|
|
48
|
+
*/
|
|
49
|
+
function isFile(p) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.statSync(p).isFile();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* check if path points to an existing directory
|
|
59
|
+
* @param {string} p - path to check
|
|
60
|
+
* @returns {boolean} - true if p is an existing directory
|
|
61
|
+
*/
|
|
62
|
+
function dirExists(p) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.statSync(p).isDirectory();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* build an env object pointing ssh-add/ssh-agent at a specific socket
|
|
72
|
+
* @param {string} sock - agent socket path, or AGENT_DEFAULT to inherit unchanged
|
|
73
|
+
* @returns {object} - environment object for the child process
|
|
74
|
+
*/
|
|
75
|
+
function sockEnv(sock) {
|
|
76
|
+
if (sock === AGENT_DEFAULT) {
|
|
77
|
+
return { ...process.env };
|
|
78
|
+
}
|
|
79
|
+
return { ...process.env, SSH_AUTH_SOCK: sock };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* whether this platform can spawn and manage a shared ssh-agent
|
|
84
|
+
* @returns {boolean} - true on POSIX platforms with a uid
|
|
85
|
+
*/
|
|
86
|
+
function canSpawnSharedAgent() {
|
|
87
|
+
return process.platform !== "win32" && typeof process.getuid === "function";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* decide whether key-based auth for this host should go through an ssh-agent
|
|
92
|
+
* @param {object} hostInfo - hostinfo object
|
|
93
|
+
* @returns {boolean} - true to use the agent path, false for the legacy pty path
|
|
94
|
+
*/
|
|
95
|
+
function shouldUseAgent(hostInfo) {
|
|
96
|
+
if (hostInfo.useAgent === false) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (typeof hostInfo.identityAgent === "string" && hostInfo.identityAgent !== "") {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (hostInfo.useAgent === true) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return typeof hostInfo.keyFile === "string" && isFile(hostInfo.keyFile);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* resolve a mode-0700, uid-owned directory to hold the shared agent socket
|
|
110
|
+
* @returns {string} - directory path
|
|
111
|
+
*/
|
|
112
|
+
function resolveWellKnownDir() {
|
|
113
|
+
const uid = process.getuid();
|
|
114
|
+
const candidates = [];
|
|
115
|
+
|
|
116
|
+
if (process.env.SSH_CLIENT_WRAPPER_AGENT_DIR) {
|
|
117
|
+
candidates.push(process.env.SSH_CLIENT_WRAPPER_AGENT_DIR);
|
|
118
|
+
}
|
|
119
|
+
if (process.env.XDG_RUNTIME_DIR) {
|
|
120
|
+
candidates.push(path.join(process.env.XDG_RUNTIME_DIR, "ssh-client-wrapper"));
|
|
121
|
+
}
|
|
122
|
+
if (dirExists(`/run/user/${uid}`)) {
|
|
123
|
+
candidates.push(`/run/user/${uid}/ssh-client-wrapper`);
|
|
124
|
+
}
|
|
125
|
+
candidates.push(path.join(os.tmpdir(), `scw-${uid}`), `/tmp/scw-${uid}`);
|
|
126
|
+
|
|
127
|
+
for (const dir of candidates) {
|
|
128
|
+
try {
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
130
|
+
fs.chmodSync(dir, 0o700);
|
|
131
|
+
const st = fs.statSync(dir);
|
|
132
|
+
const shortEnough = path.join(dir, "agent.sock").length <= MAX_SOCKET_PATH;
|
|
133
|
+
if (st.uid === uid && (st.mode & 0o077) === 0 && shortEnough) {
|
|
134
|
+
return dir;
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
debugVerbose(`candidate agent dir ${dir} rejected: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const err = new Error("no usable directory for the shared ssh-agent socket");
|
|
141
|
+
err.code = "NO_AGENT_DIR";
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* check that a pre-existing socket file is owned by us and not group/world accessible
|
|
147
|
+
* @param {string} sockPath - socket path to check
|
|
148
|
+
* @returns {boolean} - true if the socket is safe to reuse
|
|
149
|
+
*/
|
|
150
|
+
function socketIsTrusted(sockPath) {
|
|
151
|
+
try {
|
|
152
|
+
const st = fs.statSync(sockPath);
|
|
153
|
+
return st.uid === process.getuid() && (st.mode & 0o077) === 0;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* take a cross-process advisory lock on the agent directory
|
|
161
|
+
* @param {string} dir - directory to lock
|
|
162
|
+
* @returns {Promise<void>} - resolves once the lock is held
|
|
163
|
+
*/
|
|
164
|
+
async function acquireLock(dir) {
|
|
165
|
+
const lock = path.join(dir, "agent.lock");
|
|
166
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
167
|
+
|
|
168
|
+
for (;;) {
|
|
169
|
+
try {
|
|
170
|
+
fs.mkdirSync(lock);
|
|
171
|
+
return;
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e.code !== "EEXIST") {
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
178
|
+
fs.rmdirSync(lock);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() > deadline) {
|
|
185
|
+
const err = new Error("timeout acquiring ssh-agent lock");
|
|
186
|
+
err.code = "LOCK_TIMEOUT";
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
await sleep(100);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* release the advisory lock on the agent directory
|
|
196
|
+
* @param {string} dir - directory to unlock
|
|
197
|
+
* @returns {void}
|
|
198
|
+
*/
|
|
199
|
+
function releaseLock(dir) {
|
|
200
|
+
try {
|
|
201
|
+
fs.rmdirSync(path.join(dir, "agent.lock"));
|
|
202
|
+
} catch {
|
|
203
|
+
//nothing to release
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* probe an agent socket with `ssh-add -l`
|
|
209
|
+
* @param {string} sock - agent socket path (or AGENT_DEFAULT)
|
|
210
|
+
* @returns {Promise<number>} - 0 has identities, 1 reachable but empty, 2 unreachable
|
|
211
|
+
*/
|
|
212
|
+
async function probeAgent(sock) {
|
|
213
|
+
try {
|
|
214
|
+
await _internal.execFileP("ssh-add", ["-l"], { env: sockEnv(sock), timeout: CHILD_TIMEOUT_MS });
|
|
215
|
+
return 0;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return e?.code === 1 ? 1 : 2;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* resolve a passphrase value from a hostInfo passphrase callback/string
|
|
223
|
+
* @param {string | Function | undefined} ph - passphrase or callback
|
|
224
|
+
* @returns {Promise<string | null>} - the passphrase, or null when unavailable
|
|
225
|
+
*/
|
|
226
|
+
async function resolvePassphrase(ph) {
|
|
227
|
+
if (typeof ph === "string") {
|
|
228
|
+
return ph;
|
|
229
|
+
}
|
|
230
|
+
if (typeof ph === "function") {
|
|
231
|
+
const v = await ph();
|
|
232
|
+
return typeof v === "string" ? v : null;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* add a key to an agent via `ssh-add`, answering the passphrase prompt from a pty
|
|
239
|
+
* @param {string} sock - agent socket path (or AGENT_DEFAULT)
|
|
240
|
+
* @param {string} keyFile - private key path
|
|
241
|
+
* @param {string | Function | undefined} phCallback - passphrase or callback
|
|
242
|
+
* @param {number} ttl - ssh-add -t lifetime in seconds
|
|
243
|
+
* @returns {Promise<void>} - resolves on success, rejects with err.code on failure
|
|
244
|
+
*/
|
|
245
|
+
function addKey(sock, keyFile, phCallback, ttl) {
|
|
246
|
+
return new Promise((resolve, reject)=>{
|
|
247
|
+
const pty = _internal.spawn("ssh-add", ["-t", String(ttl), keyFile], {
|
|
248
|
+
windowsHide: true,
|
|
249
|
+
env: sockEnv(sock)
|
|
250
|
+
});
|
|
251
|
+
let settled = false;
|
|
252
|
+
let badTries = 0;
|
|
253
|
+
let timer = null;
|
|
254
|
+
|
|
255
|
+
const finish = (fn, arg)=>{
|
|
256
|
+
if (settled) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
settled = true;
|
|
260
|
+
if (timer !== null) {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
pty.kill();
|
|
265
|
+
} catch {
|
|
266
|
+
//already gone
|
|
267
|
+
}
|
|
268
|
+
fn(arg);
|
|
269
|
+
};
|
|
270
|
+
const fail = (code, message)=>{
|
|
271
|
+
const err = new Error(message);
|
|
272
|
+
err.code = code;
|
|
273
|
+
finish(reject, err);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
timer = setTimeout(()=>{
|
|
277
|
+
fail("ADDKEY_TIMEOUT", `ssh-add did not finish within ${ADDKEY_TIMEOUT_SEC} sec`);
|
|
278
|
+
}, ADDKEY_TIMEOUT_SEC * 1000);
|
|
279
|
+
|
|
280
|
+
pty.onData((data)=>{
|
|
281
|
+
const output = data.toString();
|
|
282
|
+
debugVerbose(output);
|
|
283
|
+
|
|
284
|
+
if (/Could not open a connection to your authentication agent|Error connecting to agent/.test(output)) {
|
|
285
|
+
fail("NO_AGENT", "no ssh-agent is reachable");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (rePhPrompt.test(output)) {
|
|
289
|
+
resolvePassphrase(phCallback)
|
|
290
|
+
.then((v)=>{
|
|
291
|
+
if (v === null) {
|
|
292
|
+
fail("NO_PASSPHRASE", "key is encrypted but no passphrase is available");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
pty.write(`${v}\n`);
|
|
296
|
+
})
|
|
297
|
+
.catch((e)=>{
|
|
298
|
+
finish(reject, e);
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (/Bad passphrase|incorrect passphrase/i.test(output)) {
|
|
303
|
+
badTries += 1;
|
|
304
|
+
if (badTries >= MAX_BAD_PASSPHRASE || typeof phCallback !== "function") {
|
|
305
|
+
fail("BAD_PASSPHRASE", "bad passphrase for private key");
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (/Identity added/.test(output)) {
|
|
310
|
+
finish(resolve);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
pty.onExit(({ exitCode })=>{
|
|
315
|
+
if (settled) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (exitCode === 0) {
|
|
319
|
+
finish(resolve);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
fail("ADDKEY_FAILED", `ssh-add exited with ${exitCode}`);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* remove a key from an agent (best effort)
|
|
329
|
+
* @param {string} sock - agent socket path (or AGENT_DEFAULT)
|
|
330
|
+
* @param {string} keyFile - private key path
|
|
331
|
+
* @returns {Promise<void>} - always resolves
|
|
332
|
+
*/
|
|
333
|
+
async function removeKey(sock, keyFile) {
|
|
334
|
+
try {
|
|
335
|
+
await _internal.execFileP("ssh-add", ["-d", keyFile], { env: sockEnv(sock), timeout: CHILD_TIMEOUT_MS });
|
|
336
|
+
} catch {
|
|
337
|
+
//best effort only
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* spawn (or find) the per-user shared ssh-agent and return its socket path
|
|
343
|
+
* @returns {Promise<string>} - socket path of a live shared agent
|
|
344
|
+
*/
|
|
345
|
+
async function spawnSharedAgent() {
|
|
346
|
+
const dir = resolveWellKnownDir();
|
|
347
|
+
const sock = path.join(dir, "agent.sock");
|
|
348
|
+
|
|
349
|
+
await acquireLock(dir);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
if (fs.existsSync(sock)) {
|
|
353
|
+
if (!socketIsTrusted(sock)) {
|
|
354
|
+
const err = new Error(`refusing to use untrusted agent socket ${sock}`);
|
|
355
|
+
err.code = "UNTRUSTED_SOCKET";
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
if (await probeAgent(sock) !== 2) {
|
|
359
|
+
return sock;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
fs.unlinkSync(sock);
|
|
363
|
+
} catch {
|
|
364
|
+
//someone else may have cleaned it up
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
debug(`spawning shared ssh-agent at ${sock}`);
|
|
368
|
+
await _internal.execFileP("ssh-agent", ["-a", sock], { timeout: CHILD_TIMEOUT_MS });
|
|
369
|
+
return sock;
|
|
370
|
+
} finally {
|
|
371
|
+
releaseLock(dir);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* get the shared agent socket, deduplicating concurrent callers in this process
|
|
377
|
+
* @returns {Promise<string>} - socket path of a live shared agent
|
|
378
|
+
*/
|
|
379
|
+
async function getSharedAgent() {
|
|
380
|
+
if (sharedSock && socketIsTrusted(sharedSock) && await probeAgent(sharedSock) !== 2) {
|
|
381
|
+
return sharedSock;
|
|
382
|
+
}
|
|
383
|
+
if (!sharedInFlight) {
|
|
384
|
+
sharedInFlight = spawnSharedAgent()
|
|
385
|
+
.then((s)=>{
|
|
386
|
+
sharedSock = s;
|
|
387
|
+
return s;
|
|
388
|
+
})
|
|
389
|
+
.finally(()=>{
|
|
390
|
+
sharedInFlight = null;
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return sharedInFlight;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* mark hostInfo as agent-ready
|
|
398
|
+
* @param {object} hostInfo - hostinfo object
|
|
399
|
+
* @param {string} sock - resolved agent socket (or AGENT_DEFAULT)
|
|
400
|
+
* @returns {void}
|
|
401
|
+
*/
|
|
402
|
+
function setReady(hostInfo, sock) {
|
|
403
|
+
hostInfo.managedAgentSock = sock === AGENT_DEFAULT ? undefined : sock;
|
|
404
|
+
hostInfo._agentEnsuredAt = Date.now();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* ensure the private key is loaded into some reachable agent (two-step algorithm)
|
|
409
|
+
* @param {object} hostInfo - hostinfo object
|
|
410
|
+
* @param {string | null} candidate - preferred socket, AGENT_DEFAULT, or null
|
|
411
|
+
* @param {number} ttl - ssh-add -t lifetime in seconds
|
|
412
|
+
* @returns {Promise<void>} - resolves once loaded (or rejects to trigger legacy fallback)
|
|
413
|
+
*/
|
|
414
|
+
async function ensureKeyLoaded(hostInfo, candidate, ttl) {
|
|
415
|
+
if (candidate !== null) {
|
|
416
|
+
try {
|
|
417
|
+
await addKey(candidate, hostInfo.keyFile, hostInfo.passphrase, ttl);
|
|
418
|
+
setReady(hostInfo, candidate);
|
|
419
|
+
return;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (e.code !== "NO_AGENT") {
|
|
422
|
+
throw e;
|
|
423
|
+
}
|
|
424
|
+
debug("no agent at the candidate socket; will spawn a shared one");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!canSpawnSharedAgent()) {
|
|
428
|
+
const err = new Error("no reachable ssh-agent and this platform cannot spawn one");
|
|
429
|
+
err.code = "NO_AGENT";
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
const sock = await getSharedAgent();
|
|
433
|
+
await addKey(sock, hostInfo.keyFile, hostInfo.passphrase, ttl);
|
|
434
|
+
setReady(hostInfo, sock);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* for a keyFile-less host, use an already-populated agent if one is reachable
|
|
439
|
+
* @param {object} hostInfo - hostinfo object
|
|
440
|
+
* @param {string | null} candidate - preferred socket, AGENT_DEFAULT, or null
|
|
441
|
+
* @returns {Promise<void>} - resolves; leaves hostInfo on the legacy path if unusable
|
|
442
|
+
*/
|
|
443
|
+
async function ensurePreloadedAgent(hostInfo, candidate) {
|
|
444
|
+
if (candidate === null || candidate === AGENT_DEFAULT) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (await probeAgent(candidate) === 0) {
|
|
448
|
+
setReady(hostInfo, candidate);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* effective ssh-add -t lifetime for a host
|
|
454
|
+
* @param {object} hostInfo - hostinfo object
|
|
455
|
+
* @returns {number} - lifetime in seconds
|
|
456
|
+
*/
|
|
457
|
+
function effectiveTtl(hostInfo) {
|
|
458
|
+
return Number.isFinite(hostInfo.agentKeyTTL) && hostInfo.agentKeyTTL > 0
|
|
459
|
+
? Math.floor(hostInfo.agentKeyTTL)
|
|
460
|
+
: DEFAULT_TTL;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* pick the agent socket to try for a host
|
|
465
|
+
* @param {object} hostInfo - hostinfo object
|
|
466
|
+
* @returns {string | null | undefined} - socket path, AGENT_DEFAULT, null to spawn a shared agent, or undefined to give up
|
|
467
|
+
*/
|
|
468
|
+
function resolveAgentCandidate(hostInfo) {
|
|
469
|
+
if (typeof hostInfo.identityAgent === "string" && hostInfo.identityAgent !== "") {
|
|
470
|
+
return hostInfo.identityAgent;
|
|
471
|
+
}
|
|
472
|
+
if (typeof process.env.SSH_AUTH_SOCK === "string" && process.env.SSH_AUTH_SOCK !== "") {
|
|
473
|
+
return process.env.SSH_AUTH_SOCK;
|
|
474
|
+
}
|
|
475
|
+
if (canSpawnSharedAgent()) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
return process.platform === "win32" ? AGENT_DEFAULT : undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* make sure this host can authenticate through an ssh-agent, if it should
|
|
483
|
+
* @param {object} hostInfo - hostinfo object (mutated: managedAgentSock, _agentEnsuredAt)
|
|
484
|
+
* @returns {Promise<void>} - resolves; on any recoverable problem hostInfo is left on the legacy path
|
|
485
|
+
*/
|
|
486
|
+
async function ensureAgentForHost(hostInfo) {
|
|
487
|
+
if (!shouldUseAgent(hostInfo)) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const ttl = effectiveTtl(hostInfo);
|
|
491
|
+
|
|
492
|
+
if (typeof hostInfo._agentEnsuredAt === "number" && Date.now() - hostInfo._agentEnsuredAt < ttl * 500) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const candidate = resolveAgentCandidate(hostInfo);
|
|
496
|
+
|
|
497
|
+
if (candidate === undefined) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const hasKey = typeof hostInfo.keyFile === "string" && isFile(hostInfo.keyFile);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
await (hasKey ? ensureKeyLoaded(hostInfo, candidate, ttl) : ensurePreloadedAgent(hostInfo, candidate));
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e.code === "BAD_PASSPHRASE") {
|
|
506
|
+
throw e;
|
|
507
|
+
}
|
|
508
|
+
debug(`agent setup did not complete (${e.code || e.message}); using legacy pty auth`);
|
|
509
|
+
delete hostInfo.managedAgentSock;
|
|
510
|
+
delete hostInfo._agentEnsuredAt;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export {
|
|
515
|
+
shouldUseAgent,
|
|
516
|
+
canSpawnSharedAgent,
|
|
517
|
+
ensureAgentForHost,
|
|
518
|
+
removeKey,
|
|
519
|
+
probeAgent,
|
|
520
|
+
addKey,
|
|
521
|
+
resolveWellKnownDir,
|
|
522
|
+
socketIsTrusted,
|
|
523
|
+
_internal
|
|
524
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -3,12 +3,13 @@ declare module "ssh-client-wrapper" {
|
|
|
3
3
|
host: string;
|
|
4
4
|
user: string;
|
|
5
5
|
port?: number;
|
|
6
|
-
password?: string | (() => string);
|
|
7
|
-
passphrase?: string | (() => string);
|
|
6
|
+
password?: string | (() => string | Promise<string>);
|
|
7
|
+
passphrase?: string | (() => string | Promise<string>);
|
|
8
8
|
keyFile?: string;
|
|
9
9
|
noStrictHostKeyChecking?: boolean;
|
|
10
10
|
ControlPersist?: number;
|
|
11
11
|
ConnectTimeout?: number;
|
|
12
|
+
ControlPersistDir?: string;
|
|
12
13
|
maxRetry?: number;
|
|
13
14
|
retryDuration?: number;
|
|
14
15
|
retryableExitCodes?: number[];
|
|
@@ -16,6 +17,10 @@ declare module "ssh-client-wrapper" {
|
|
|
16
17
|
rcfile?: string;
|
|
17
18
|
prependCmd?: string;
|
|
18
19
|
sshOpt?: string[];
|
|
20
|
+
useAgent?: boolean;
|
|
21
|
+
identityAgent?: string;
|
|
22
|
+
agentKeyTTL?: number;
|
|
23
|
+
reauthRequired?: () => void;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
class SshClientWrapper {
|
|
@@ -60,7 +65,15 @@ declare module "ssh-client-wrapper" {
|
|
|
60
65
|
replaceRetryableExitCodes?: boolean
|
|
61
66
|
): Promise<void>;
|
|
62
67
|
canConnect(timeout?: number): Promise<boolean>;
|
|
68
|
+
remoteToRemoteCopy(
|
|
69
|
+
src: string[],
|
|
70
|
+
dstHostInfo: HostInfo,
|
|
71
|
+
dst: string,
|
|
72
|
+
opt?: string[],
|
|
73
|
+
timeout?: number
|
|
74
|
+
): Promise<void>;
|
|
63
75
|
disconnect(): Promise<void>;
|
|
76
|
+
dispose(): Promise<void>;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export default SshClientWrapper;
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import Debug from "debug";
|
|
4
4
|
import * as sshExecModule from "./sshExec.js";
|
|
5
5
|
import * as rsyncExecModule from "./rsyncExec.js";
|
|
6
|
+
import * as agentModule from "./agent.js";
|
|
6
7
|
import { isArrayOfString, isArrayOfInteger, sanityCheck } from "./util.js";
|
|
7
8
|
|
|
8
9
|
const debug = Debug("sshClientWrapper:debug:interface");
|
|
@@ -35,6 +36,10 @@ const debugVerbose = Debug("sshClientWrapper:verbose:interface");
|
|
|
35
36
|
* @param {string} hostInfo.rcfile - rcfile path which will be sourced before executing actual command
|
|
36
37
|
* @param {string} hostInfo.prependCmd - command string which will be executed before executing actual command
|
|
37
38
|
* @param {string[]} hostInfo.sshOpt - additional options for ssh
|
|
39
|
+
* @param {boolean} hostInfo.useAgent - authenticate key-based hosts through an ssh-agent (default: true when keyFile is set). On win32 an already-running agent is used but none is spawned. Set false to force the legacy pty passphrase-replay path.
|
|
40
|
+
* @param {string} hostInfo.identityAgent - path to an existing ssh-agent socket to use (emitted as -oIdentityAgent); the wrapper manages nothing when this is set
|
|
41
|
+
* @param {Integer} hostInfo.agentKeyTTL - lifetime in seconds for keys added to the agent (ssh-add -t); default 3600
|
|
42
|
+
* @param {Function} hostInfo.reauthRequired - called when a fresh master connection hits an auth prompt despite the agent (unused by current consumers)
|
|
38
43
|
*/
|
|
39
44
|
|
|
40
45
|
const logAndReject = (message)=>{
|
|
@@ -42,12 +47,14 @@ const logAndReject = (message)=>{
|
|
|
42
47
|
return Promise.reject(new Error(message));
|
|
43
48
|
};
|
|
44
49
|
|
|
50
|
+
const defaultDeps = { ...sshExecModule, ...rsyncExecModule, ...agentModule };
|
|
51
|
+
|
|
45
52
|
/**
|
|
46
53
|
* Facade class.
|
|
47
54
|
* @class
|
|
48
55
|
*/
|
|
49
56
|
class SshClientWrapper {
|
|
50
|
-
constructor(hostInfo, deps =
|
|
57
|
+
constructor(hostInfo, deps = defaultDeps) {
|
|
51
58
|
debug("constructor called for", hostInfo.host);
|
|
52
59
|
debugVerbose("hostInfo=", hostInfo);
|
|
53
60
|
this.hostInfo = { ...hostInfo };
|
|
@@ -281,6 +288,22 @@ class SshClientWrapper {
|
|
|
281
288
|
debug(`disconnect from ${this.hostInfo.host} called`);
|
|
282
289
|
this.deps.disconnect(this.hostInfo);
|
|
283
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Remove this host's key from the wrapper-managed / external agent and tear down the
|
|
294
|
+
* master session. Does NOT kill the shared agent process (other hosts or processes may
|
|
295
|
+
* be using it). Safe to call repeatedly. (Unused by current consumers.)
|
|
296
|
+
* @returns {Promise<void>} - resolves when teardown is done
|
|
297
|
+
*/
|
|
298
|
+
async dispose() {
|
|
299
|
+
debug(`dispose for ${this.hostInfo.host} called`);
|
|
300
|
+
if (this.hostInfo.managedAgentSock && this.hostInfo.keyFile) {
|
|
301
|
+
await this.deps.removeKey(this.hostInfo.managedAgentSock, this.hostInfo.keyFile);
|
|
302
|
+
}
|
|
303
|
+
delete this.hostInfo.managedAgentSock;
|
|
304
|
+
delete this.hostInfo._agentEnsuredAt;
|
|
305
|
+
await this.deps.disconnect(this.hostInfo);
|
|
306
|
+
}
|
|
284
307
|
}
|
|
285
308
|
|
|
286
309
|
export default SshClientWrapper;
|
package/lib/sshExec.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { setTimeout as setTimeoutPromise } from "timers/promises";
|
|
3
3
|
import Debug from "debug";
|
|
4
|
-
import { sendPty, sshCmd, getSshOption, sanityCheck, watchDogTimer } from "./util.js";
|
|
4
|
+
import { sendPty, sshCmd, getSshOption, sanityCheck, watchDogTimer, rePwPrompt, rePhPrompt } from "./util.js";
|
|
5
5
|
import { fork, createMasterPty, sshLoginCallback } from "./fork.js";
|
|
6
|
+
import { shouldUseAgent, ensureAgentForHost } from "./agent.js";
|
|
6
7
|
|
|
7
8
|
const debug = Debug("sshClientWrapper:debug:sshExec");
|
|
8
9
|
const debugVerbose = Debug("sshClientWrapper:verbose:sshExec");
|
|
@@ -147,6 +148,22 @@ async function existsMaster(hostInfo, argTimeout) {
|
|
|
147
148
|
export async function connect(hostInfo, timeout = 60) {
|
|
148
149
|
debug("connect called");
|
|
149
150
|
|
|
151
|
+
if (shouldUseAgent(hostInfo)) {
|
|
152
|
+
try {
|
|
153
|
+
await ensureAgentForHost(hostInfo);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e.code === "BAD_PASSPHRASE") {
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
158
|
+
debug("agent setup failed; falling back to legacy pty auth", e);
|
|
159
|
+
delete hostInfo.managedAgentSock;
|
|
160
|
+
delete hostInfo._agentEnsuredAt;
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
delete hostInfo.managedAgentSock;
|
|
164
|
+
delete hostInfo._agentEnsuredAt;
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
if (await existsMaster(hostInfo, timeout)) {
|
|
151
168
|
debugVerbose("master connection exists");
|
|
152
169
|
return;
|
|
@@ -201,6 +218,22 @@ export async function connect(hostInfo, timeout = 60) {
|
|
|
201
218
|
});
|
|
202
219
|
hostInfo.masterPty.onData((data)=>{
|
|
203
220
|
const output = data.toString();
|
|
221
|
+
|
|
222
|
+
if (shouldUseAgent(hostInfo) && (rePwPrompt.test(output) || rePhPrompt.test(output))) {
|
|
223
|
+
if (typeof hostInfo.reauthRequired === "function") {
|
|
224
|
+
try {
|
|
225
|
+
hostInfo.reauthRequired();
|
|
226
|
+
} catch {
|
|
227
|
+
//a consumer hook must never break the connection
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (hostInfo.password == null && hostInfo.passphrase == null) {
|
|
231
|
+
const err = new Error("agent authentication failed and no secret is available for fallback");
|
|
232
|
+
err.code = "REAUTH_REQUIRED";
|
|
233
|
+
reject(err);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
204
237
|
sshLoginCallback(output, hostInfo.masterPty, hostInfo.password, hostInfo.passphrase, debugSendMasterPty)
|
|
205
238
|
.catch(reject);
|
|
206
239
|
});
|
package/lib/util.js
CHANGED
|
@@ -53,7 +53,10 @@ const hostInfoSchema = {
|
|
|
53
53
|
type: "array",
|
|
54
54
|
minItems: 1,
|
|
55
55
|
items: { type: "string", pattern: "\\S+", transform: ["trim"] }
|
|
56
|
-
}
|
|
56
|
+
},
|
|
57
|
+
useAgent: { type: "boolean" },
|
|
58
|
+
identityAgent: { type: "string", pattern: String.raw`\S+`, transform: ["trim"] },
|
|
59
|
+
agentKeyTTL: { type: "number", minimum: 1 }
|
|
57
60
|
},
|
|
58
61
|
required: ["host"]
|
|
59
62
|
};
|
|
@@ -61,13 +64,15 @@ const hostInfoSchema = {
|
|
|
61
64
|
const stringOptions = [
|
|
62
65
|
"user",
|
|
63
66
|
"keyFile",
|
|
64
|
-
"ControlPersistDir"
|
|
67
|
+
"ControlPersistDir",
|
|
68
|
+
"identityAgent"
|
|
65
69
|
];
|
|
66
70
|
const numberOptions = [
|
|
67
71
|
"ControlPersist",
|
|
68
72
|
"ConnectTimeout",
|
|
69
73
|
"maxRetry",
|
|
70
|
-
"retryDuration"
|
|
74
|
+
"retryDuration",
|
|
75
|
+
"agentKeyTTL"
|
|
71
76
|
];
|
|
72
77
|
|
|
73
78
|
const validate = ajv.compile(hostInfoSchema);
|
|
@@ -133,6 +138,11 @@ export const sanityCheck = (hostInfo)=>{
|
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
}
|
|
141
|
+
if (typeof hostInfo.identityAgent === "string" && /\s/.test(hostInfo.identityAgent)) {
|
|
142
|
+
const err = new Error(`invalid identityAgent specified ${hostInfo.identityAgent}`);
|
|
143
|
+
err.hostInfo = hostInfo;
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
136
146
|
if (["string", "function"].includes(typeof password)) {
|
|
137
147
|
hostInfo.password = password;
|
|
138
148
|
}
|
|
@@ -211,7 +221,13 @@ export const getSshOption = (hostInfo, withoutDestination)=>{
|
|
|
211
221
|
if (hostInfo.keyFile) {
|
|
212
222
|
args.push("-A");
|
|
213
223
|
}
|
|
214
|
-
|
|
224
|
+
|
|
225
|
+
//route auth through a wrapper-managed or caller-supplied ssh-agent when one is in play
|
|
226
|
+
const agentSock = hostInfo.managedAgentSock || hostInfo.identityAgent;
|
|
227
|
+
if (typeof agentSock === "string" && agentSock !== "") {
|
|
228
|
+
args.push(`-oIdentityAgent=${agentSock}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
215
231
|
const controlPersist = Number.isInteger(hostInfo.ControlPersist) && hostInfo.ControlPersist >= 0 ? hostInfo.ControlPersist : "180";
|
|
216
232
|
const controlPersistDir = getControlPersistDir(hostInfo);
|
|
217
233
|
args.push("-oControlMaster=auto");
|