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 CHANGED
@@ -1,7 +1,7 @@
1
1
  [![npm version](https://badge.fury.io/js/ssh-client-wrapper.svg)](https://badge.fury.io/js/ssh-client-wrapper)
2
2
  [![Known Vulnerabilities](https://snyk.io/test/github/so5/ssh-client-wrapper/badge.svg)](https://snyk.io/test/github/so5/ssh-client-wrapper)
3
3
  [![Coverage Status](https://coveralls.io/repos/github/so5/ssh-client-wrapper/badge.svg?branch=main)](https://coveralls.io/github/so5/ssh-client-wrapper?branch=main)
4
- [![Maintainability](https://api.codeclimate.com/v1/badges/8f3c0ea00e755ae31081/maintainability)](https://codeclimate.com/github/so5/ssh-client-wrapper/maintainability)
4
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=so5_ssh-client-wrapper&metric=sqale_rating)](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 = { ...sshExecModule, ...rsyncExecModule }) {
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-client-wrapper",
3
- "version": "2.17.1",
3
+ "version": "3.0.0",
4
4
  "description": "wrapper library for open ssh client and rsync",
5
5
  "keywords": [
6
6
  "ssh",