ssh-client-wrapper 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/lib/remotePath.js +124 -0
- package/lib/rsyncExec.js +29 -16
- package/lib/sshExec.js +14 -1
- package/lib/util.js +19 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Creates a new SSH client instance.
|
|
|
56
56
|
* `host` (string): The hostname or IP address of the server.
|
|
57
57
|
* `user` (string): The username for authentication.
|
|
58
58
|
* `port` (number, optional): The port number. Defaults to 22.
|
|
59
|
-
* `password` (string | Function, optional): The password for authentication, or a function that returns the password.
|
|
59
|
+
* `password` (string | Function, optional): The password for authentication, or a function that returns the password. See [Wrong password](#wrong-password).
|
|
60
60
|
* `keyFile` (string, optional): The path to the private key file for key-based authentication.
|
|
61
61
|
* `passphrase` (string | Function, optional): The passphrase for the private key, or a function that returns it.
|
|
62
62
|
* `noStrictHostKeyChecking` (boolean, optional): If `true`, bypasses strict host key checking.
|
|
@@ -107,6 +107,17 @@ Downloads files or directories from the remote host using `rsync`.
|
|
|
107
107
|
* `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in default instead of adding to it. Defaults to `false`.
|
|
108
108
|
* Returns: `Promise<void>`
|
|
109
109
|
|
|
110
|
+
### Remote paths of `.send()` / `.recv()`
|
|
111
|
+
|
|
112
|
+
A remote path (`dst` of `.send()`, `src` of `.recv()`) is interpreted by the login shell of the remote host, whatever the version or the implementation of the local `rsync` is:
|
|
113
|
+
|
|
114
|
+
* `~`, environment variables (`$HOME/work`), braces (`dir/{a,b}`; the remote shell must support them, e.g. bash), quotes (`'dir/a b'`) and backslash escapes (`dir/a\ b`) are expanded.
|
|
115
|
+
* A space which is neither quoted nor escaped separates two paths.
|
|
116
|
+
* Wildcards (`*`, `?`, `[...]`) are expanded by the remote side as usual.
|
|
117
|
+
* The destination of `.send()` must be expanded to exactly one path. Its directory is created if it does not exist.
|
|
118
|
+
|
|
119
|
+
A path that consists only of letters, digits and `_ @ % + = : , . / -` and wildcards needs no expansion and costs no extra round trip. Otherwise the expansion is done by one extra command over the existing connection.
|
|
120
|
+
|
|
110
121
|
### Retry behavior for `.send()` / `.recv()`
|
|
111
122
|
|
|
112
123
|
`.send()` and `.recv()` automatically retry (up to `hostInfo.maxRetry` times, default `3`, waiting `hostInfo.retryDuration` msec between attempts, default `1000`) when:
|
|
@@ -179,6 +190,15 @@ Checks if a connection to the remote host can be established.
|
|
|
179
190
|
|
|
180
191
|
* Returns: `Promise<boolean>` - Resolves with `true` on success.
|
|
181
192
|
|
|
193
|
+
### Wrong password
|
|
194
|
+
|
|
195
|
+
If the login is refused, the promise is rejected with an `Error` whose `message` is `Permission denied` and whose `code` is `AUTH_FAILED`. `host`, `user` and `port` tell which login failed.
|
|
196
|
+
|
|
197
|
+
* `password` / `passphrase` given as a **string**: ssh is asked to try only once (`-oNumberOfPasswordPrompts=1`), because sending the same wrong string again can not succeed (and may lock the account). The promise is rejected at once.
|
|
198
|
+
* `password` / `passphrase` given as a **function**: the function is called each time ssh asks (up to three times, the ssh default), so that it can ask the user again and return another value. If none of them is accepted the promise is rejected as above.
|
|
199
|
+
|
|
200
|
+
Whether to ask the user again after an `AUTH_FAILED` is up to the application.
|
|
201
|
+
|
|
182
202
|
### `.disconnect()`
|
|
183
203
|
Closes the master SSH connection to the remote host.
|
|
184
204
|
|
|
@@ -202,3 +222,36 @@ If no agent can be used, or `ssh-add` fails, the wrapper silently falls back to
|
|
|
202
222
|
**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
223
|
|
|
204
224
|
**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.
|
|
225
|
+
|
|
226
|
+
## Running the tests
|
|
227
|
+
|
|
228
|
+
The tests are split into two groups:
|
|
229
|
+
|
|
230
|
+
| command | what runs | needs test hosts |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| `npm run test:unit` | `test/unit`: option building, validation, agent handling, ... | no |
|
|
233
|
+
| `npm run test:integration` | `test/integration`: real `ssh` and `rsync` against test hosts | yes |
|
|
234
|
+
| `npm run mocha` | both | yes |
|
|
235
|
+
| `npm test` | both, with coverage | yes |
|
|
236
|
+
|
|
237
|
+
`npm run test:unit` prints a reminder that **the integration tests were not run**. A green unit run does not say anything about ssh or rsync.
|
|
238
|
+
|
|
239
|
+
The integration tests check the test hosts first. If a required environment variable is missing, or a host can not be logged in, the whole run fails at once with a message that says what to check (it does not wait for timeouts of the tests).
|
|
240
|
+
|
|
241
|
+
### Test hosts
|
|
242
|
+
|
|
243
|
+
The easiest way is `./run_test_local.sh`. It starts two [`naoso5/test-sshd`](https://hub.docker.com/r/naoso5/test-sshd) containers with Docker Compose, installs a throw-away key, starts an ssh-agent, sets the variables below and runs `npm test`. (If you run it with a different `HOME`, keep the path short: the ssh-agent socket path is limited to about 100 characters.)
|
|
244
|
+
|
|
245
|
+
To use your own hosts, set these environment variables:
|
|
246
|
+
|
|
247
|
+
| variable | meaning |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `TEST_HOST`, `TEST_PORT`, `TEST_USER` | the test host (required: `TEST_HOST`) |
|
|
250
|
+
| `TEST_PW` or `TEST_KEYFILE` | password or private key of the test user (an ssh-agent also works) |
|
|
251
|
+
| `TEST_HOST2`, `TEST_PORT2`, `TEST_USER2` | the second host used by `remoteToRemoteCopy`, **as seen from the first test host** (required: `TEST_HOST2`; `TEST_USER2` defaults to `TEST_USER`) |
|
|
252
|
+
| `TEST_HOST2_VERIFY`, `TEST_PORT2_VERIFY` | the same second host, **as seen from the machine which runs the tests** (default: `TEST_HOST2`, `TEST_PORT2`) |
|
|
253
|
+
| `TEST_KEEP_FILES` | if set, the files created on the test hosts are not removed |
|
|
254
|
+
|
|
255
|
+
The two ways to name the second host differ when the hosts are Docker containers: the first container reaches the second one by its container name (`testbed2:22`), while the test runner reaches it through a published port (`localhost:4101`).
|
|
256
|
+
|
|
257
|
+
The same tests run in GitHub Actions for Node.js 22/24 and for Debian (see `.github/workflows`). To run the Debian workflow locally, see the comment at the top of `run_test_debian.yml`.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import Debug from "debug";
|
|
3
|
+
|
|
4
|
+
import { sshExec } from "./sshExec.js";
|
|
5
|
+
|
|
6
|
+
const debug = Debug("sshClientWrapper:debug:remotePath");
|
|
7
|
+
|
|
8
|
+
//characters which need no shell expansion. wildcards are left to the remote side.
|
|
9
|
+
const notSimpleChar = /[^\w@%+=:,./*?[\]-]/;
|
|
10
|
+
const notSimpleCharGlobal = new RegExp(notSimpleChar.source, "g");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* check if the remote rsync arguments are taken literally by rsync.
|
|
14
|
+
* rsync 3.2.4 or later does not pass remote path to the remote shell by default.
|
|
15
|
+
* older rsync, openrsync (reports 2.6.9) and rsync4win (reports 0.x) let the remote shell evaluate it.
|
|
16
|
+
* @param {object} version - rsync version object ({major, minor, patch})
|
|
17
|
+
* @returns {boolean} - true if the remote path is taken literally
|
|
18
|
+
*/
|
|
19
|
+
export function hasLiteralRemoteArgs(version) {
|
|
20
|
+
if (!version
|
|
21
|
+
|| !Number.isInteger(version.major)
|
|
22
|
+
|| !Number.isInteger(version.minor)
|
|
23
|
+
|| !Number.isInteger(version.patch)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const { major, minor, patch } = version;
|
|
27
|
+
if (major !== 3) {
|
|
28
|
+
return major > 3;
|
|
29
|
+
}
|
|
30
|
+
if (minor !== 2) {
|
|
31
|
+
return minor > 2;
|
|
32
|
+
}
|
|
33
|
+
return patch >= 4;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* check if the remote path contains something which must be evaluated by the remote shell
|
|
38
|
+
* @param {string} pathSpec - remote path given by the caller
|
|
39
|
+
* @returns {boolean} - true if the remote path need to be expanded
|
|
40
|
+
*/
|
|
41
|
+
export function needsExpansion(pathSpec) {
|
|
42
|
+
return notSimpleChar.test(pathSpec);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* escape all characters except wildcards with backslash, so that the remote shell passes it as is.
|
|
47
|
+
* @param {string} word - remote path
|
|
48
|
+
* @returns {string} - escaped remote path
|
|
49
|
+
*/
|
|
50
|
+
export function escapeRemoteArg(word) {
|
|
51
|
+
return word.replace(notSimpleCharGlobal, "\\$&");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* quote a string with single quote for the remote shell. wildcards are NOT expanded.
|
|
56
|
+
* @param {string} word - string
|
|
57
|
+
* @returns {string} - quoted string
|
|
58
|
+
*/
|
|
59
|
+
export function shellQuote(word) {
|
|
60
|
+
return `'${word.replace(/'/g, "'\\''")}'`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* convert an expanded remote path to the form the local rsync expects.
|
|
65
|
+
* @param {string} word - expanded remote path
|
|
66
|
+
* @param {boolean} literal - true if the local rsync takes remote path literally
|
|
67
|
+
* @returns {string} - remote path for rsync command line
|
|
68
|
+
*/
|
|
69
|
+
export function formatRemoteArg(word, literal) {
|
|
70
|
+
return literal ? word : escapeRemoteArg(word);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* make command line which print expanded remote path.
|
|
75
|
+
* filename expansion (wildcards) is disabled. it is left to the remote rsync.
|
|
76
|
+
* @param {string} pathSpec - remote path given by the caller
|
|
77
|
+
* @param {string} token - random string to find the result in output
|
|
78
|
+
* @returns {string} - command line for remote shell
|
|
79
|
+
*/
|
|
80
|
+
export function buildExpandCommand(pathSpec, token) {
|
|
81
|
+
return `set -f; echo ${token}B; for f in ${pathSpec}; do printf '%s\\n' "$f"; done; echo ${token}E`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* pick up expanded words from the output of command which is made by buildExpandCommand
|
|
86
|
+
* @param {string} output - whole output
|
|
87
|
+
* @param {string} token - random string which was given to buildExpandCommand
|
|
88
|
+
* @returns {string[] | null} - expanded words, or null if the output can not be parsed
|
|
89
|
+
*/
|
|
90
|
+
export function parseExpandOutput(output, token) {
|
|
91
|
+
const lines = output.split(/\r?\n/);
|
|
92
|
+
const begin = lines.indexOf(`${token}B`);
|
|
93
|
+
const end = lines.lastIndexOf(`${token}E`);
|
|
94
|
+
if (begin < 0 || end < begin) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return lines.slice(begin + 1, end);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* let the remote shell expand the remote path (tilde, variables, braces, quotes, and backslash escapes).
|
|
102
|
+
* if the remote shell can not do it, the path is returned as is.
|
|
103
|
+
* @param {object} hostInfo - hostinfo object
|
|
104
|
+
* @param {string} pathSpec - remote path given by the caller
|
|
105
|
+
* @param {number} timeout - timeout in seconds
|
|
106
|
+
* @returns {Promise<string[]>} - expanded paths
|
|
107
|
+
*/
|
|
108
|
+
export async function expandRemotePath(hostInfo, pathSpec, timeout = 0) {
|
|
109
|
+
if (!needsExpansion(pathSpec)) {
|
|
110
|
+
return [pathSpec];
|
|
111
|
+
}
|
|
112
|
+
const token = `SCW${crypto.randomBytes(8).toString("hex")}`;
|
|
113
|
+
const chunks = [];
|
|
114
|
+
const rt = await sshExec(hostInfo, buildExpandCommand(pathSpec, token), timeout, (data)=>{
|
|
115
|
+
chunks.push(data);
|
|
116
|
+
});
|
|
117
|
+
const words = parseExpandOutput(chunks.join(""), token);
|
|
118
|
+
if (rt !== 0 || words === null || words.length === 0) {
|
|
119
|
+
debug(`can not expand remote path on ${hostInfo.host}, use it as is: ${pathSpec}`);
|
|
120
|
+
return [pathSpec];
|
|
121
|
+
}
|
|
122
|
+
debug(`remote path ${pathSpec} is expanded to`, words);
|
|
123
|
+
return words;
|
|
124
|
+
}
|
package/lib/rsyncExec.js
CHANGED
|
@@ -7,6 +7,7 @@ import { connect, retryWrapper, sshExec } from "./sshExec.js";
|
|
|
7
7
|
import { fork } from "./fork.js";
|
|
8
8
|
|
|
9
9
|
import { sshCmd, rsyncCmd, getSshOption, resolveRetryableExitCodes } from "./util.js";
|
|
10
|
+
import { expandRemotePath, formatRemoteArg, hasLiteralRemoteArgs, shellQuote } from "./remotePath.js";
|
|
10
11
|
|
|
11
12
|
const debug = Debug("sshClientWrapper:debug:rsyncExec");
|
|
12
13
|
const debugVerbose = Debug("sshClientWrapper:verbose:rsyncExec");
|
|
@@ -67,39 +68,47 @@ export async function send(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableEx
|
|
|
67
68
|
const sshArgs = getSshOption(hostInfo, true);
|
|
68
69
|
const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
//the remote path is interpreted by the remote shell (~, $VAR, {a,b}, quotes and escapes)
|
|
72
|
+
const remoteDsts = await expandRemotePath(hostInfo, dst);
|
|
73
|
+
if (remoteDsts.length !== 1) {
|
|
74
|
+
throw new Error(`dst must be expanded to exactly one path: ${dst}`);
|
|
75
|
+
}
|
|
76
|
+
const [remoteDst] = remoteDsts;
|
|
77
|
+
const literal = await hasLiteralRemoteArgsOfLocalRsync(hostInfo);
|
|
78
|
+
|
|
79
|
+
const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, `${hostInfo.host}:${formatRemoteArg(remoteDst, literal)}`];
|
|
71
80
|
let remoteDir;
|
|
72
|
-
if (
|
|
73
|
-
remoteDir =
|
|
81
|
+
if (remoteDst.endsWith("/")) {
|
|
82
|
+
remoteDir = remoteDst;
|
|
74
83
|
} else {
|
|
75
|
-
const { dir } = path.parse(
|
|
84
|
+
const { dir } = path.parse(remoteDst);
|
|
76
85
|
remoteDir = dir;
|
|
77
86
|
}
|
|
78
87
|
debug("remoteDir=", remoteDir);
|
|
79
88
|
|
|
80
89
|
if (remoteDir !== "" && remoteDir !== "/") {
|
|
81
90
|
debug(`mkdir ${remoteDir}`);
|
|
82
|
-
sshExec(hostInfo, `mkdir -p ${remoteDir}`, debugVerbose);
|
|
91
|
+
await sshExec(hostInfo, `mkdir -p ${shellQuote(remoteDir)}`, 0, debugVerbose);
|
|
83
92
|
}
|
|
84
|
-
debug(`send ${src} to ${hostInfo.host}:${
|
|
93
|
+
debug(`send ${src} to ${hostInfo.host}:${remoteDst}`);
|
|
85
94
|
debugVerbose(`send: ${rsyncCmd} ${args.join(" ")}`);
|
|
86
95
|
const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
|
|
87
96
|
return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/**
|
|
91
|
-
*
|
|
100
|
+
* check if the local rsync takes remote path literally (rsync 3.2.4 or later) or lets the remote shell evaluate it
|
|
92
101
|
* @param {object} hostInfo - hostinfo object
|
|
93
|
-
* @returns {boolean} -
|
|
102
|
+
* @returns {boolean} - true if the local rsync takes remote path literally
|
|
94
103
|
*/
|
|
95
|
-
async function
|
|
104
|
+
async function hasLiteralRemoteArgsOfLocalRsync(hostInfo) {
|
|
96
105
|
if (!hostInfo.rsyncVersion
|
|
97
106
|
|| !Number.isInteger(hostInfo.rsyncVersion.major)
|
|
98
107
|
|| !Number.isInteger(hostInfo.rsyncVersion.minor)
|
|
99
108
|
|| !Number.isInteger(hostInfo.rsyncVersion.patch)) {
|
|
100
109
|
hostInfo.rsyncVersion = await checkRsyncVersion(3);
|
|
101
110
|
}
|
|
102
|
-
return hostInfo.rsyncVersion
|
|
111
|
+
return hasLiteralRemoteArgs(hostInfo.rsyncVersion);
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
/**
|
|
@@ -118,16 +127,20 @@ export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableEx
|
|
|
118
127
|
await connect(hostInfo);
|
|
119
128
|
//pass each source as its own "host:path" argument. Joining them into one
|
|
120
129
|
//space separated argument relies on the remote shell to split it, which
|
|
121
|
-
//rsync >= 3.4 (protected args) no longer does.
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
//rsync >= 3.2.4 (protected args) no longer does.
|
|
131
|
+
//the remote path is interpreted by the remote shell (~, $VAR, {a,b}, quotes and escapes).
|
|
132
|
+
//it is expanded here, so that the result does not depend on how the local rsync handles it.
|
|
133
|
+
const literal = await hasLiteralRemoteArgsOfLocalRsync(hostInfo);
|
|
134
|
+
const remoteSrcs = [];
|
|
135
|
+
for (const e of argSrc) {
|
|
136
|
+
remoteSrcs.push(...await expandRemotePath(hostInfo, e));
|
|
137
|
+
}
|
|
138
|
+
const src = remoteSrcs.map((e)=>{
|
|
139
|
+
return `${hostInfo.host}:${formatRemoteArg(e, literal)}`;
|
|
124
140
|
});
|
|
125
141
|
const sshArgs = getSshOption(hostInfo, true);
|
|
126
142
|
const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
|
|
127
143
|
const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, dst];
|
|
128
|
-
if (await isNewRsync(hostInfo)) {
|
|
129
|
-
args.unshift("--old-args");
|
|
130
|
-
}
|
|
131
144
|
|
|
132
145
|
//make destination directory
|
|
133
146
|
const dstDir = argSrc.len > 1 || hasMagic(argSrc[0]) ? dst : path.dirname(dst);
|
package/lib/sshExec.js
CHANGED
|
@@ -205,7 +205,12 @@ export async function connect(hostInfo, timeout = 60) {
|
|
|
205
205
|
done = true;
|
|
206
206
|
resolve();
|
|
207
207
|
} else if (/Permission denied \(/.test(output)) {
|
|
208
|
-
|
|
208
|
+
const err = new Error("Permission denied");
|
|
209
|
+
err.code = "AUTH_FAILED";
|
|
210
|
+
err.host = hostInfo.host;
|
|
211
|
+
err.user = hostInfo.user;
|
|
212
|
+
err.port = hostInfo.port;
|
|
213
|
+
reject(err);
|
|
209
214
|
} else if (/Could not resolve hostname/.test(output)) {
|
|
210
215
|
reject(new Error("Could not resolve hostname"));
|
|
211
216
|
} else if (/Bad port/.test(output)) {
|
|
@@ -251,6 +256,14 @@ export async function connect(hostInfo, timeout = 60) {
|
|
|
251
256
|
try {
|
|
252
257
|
await p;
|
|
253
258
|
debug(`connected to ${hostInfo.host}`);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
//the pty of a failed connection must not be left behind. It keeps the process alive.
|
|
261
|
+
debug(`failed to connect to ${hostInfo.host}, kill master pty`);
|
|
262
|
+
if (hostInfo.masterPty) {
|
|
263
|
+
hostInfo.masterPty.kill();
|
|
264
|
+
hostInfo.masterPty = null;
|
|
265
|
+
}
|
|
266
|
+
throw e;
|
|
254
267
|
} finally {
|
|
255
268
|
if (ac !== null) {
|
|
256
269
|
ac.abort();
|
package/lib/util.js
CHANGED
|
@@ -155,6 +155,20 @@ export const sanityCheck = (hostInfo)=>{
|
|
|
155
155
|
return hostInfo;
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* check if the password and the passphrase can not change between the prompts of ssh
|
|
160
|
+
* @param {object} hostInfo - host information object defined in index.js
|
|
161
|
+
* @returns {boolean} - true if at least one of them is a string and none of them is a function
|
|
162
|
+
*/
|
|
163
|
+
function hasFixedSecret(hostInfo) {
|
|
164
|
+
const secrets = [hostInfo.password, hostInfo.passphrase];
|
|
165
|
+
return secrets.some((e)=>{
|
|
166
|
+
return typeof e === "string";
|
|
167
|
+
}) && !secrets.some((e)=>{
|
|
168
|
+
return typeof e === "function";
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
158
172
|
function getControlPersistDir(hostInfo) {
|
|
159
173
|
for (const candidate of [hostInfo.ControlPersistDir, process.env.SSH_CONTROL_PERSIST_DIR]) {
|
|
160
174
|
if (typeof candidate === "string") {
|
|
@@ -237,6 +251,11 @@ export const getSshOption = (hostInfo, withoutDestination)=>{
|
|
|
237
251
|
if (Number.isInteger(hostInfo.ConnectTimeout) && hostInfo.ConnectTimeout > 0) {
|
|
238
252
|
args.push(`-oConnectTimeout=${hostInfo.ConnectTimeout}`);
|
|
239
253
|
}
|
|
254
|
+
//ssh asks three times by default. If the secret is a fixed string, asking again with the same string is
|
|
255
|
+
//of no use. A function can give another secret each time, so it keeps the default.
|
|
256
|
+
if (hasFixedSecret(hostInfo)) {
|
|
257
|
+
args.push("-oNumberOfPasswordPrompts=1");
|
|
258
|
+
}
|
|
240
259
|
if (Array.isArray(hostInfo.sshOpt)) {
|
|
241
260
|
args.push(...hostInfo.sshOpt.filter((e)=>{
|
|
242
261
|
return typeof e === "string";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-client-wrapper",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
4
4
|
"description": "wrapper library for open ssh client and rsync",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ssh",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"types": "lib/index.d.ts",
|
|
17
17
|
"scripts": {
|
|
18
18
|
"mocha": "mocha --recursive test",
|
|
19
|
+
"test:unit": "mocha --recursive test/unit && echo \"NOTE: only unit tests were run. The integration tests (ssh, rsync) were NOT run: use npm run test:integration with test hosts\"",
|
|
20
|
+
"test:integration": "mocha --recursive test/integration",
|
|
19
21
|
"lint": "eslint --fix ./",
|
|
20
22
|
"test": "c8 --reporter=html --reporter=text mocha --recursive test",
|
|
21
23
|
"coverage": "c8 report --reporter=text-lcov > coverage-report.lcov",
|