ssh-client-wrapper 3.1.0 → 3.1.2
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 +44 -0
- package/lib/remotePath.js +124 -0
- package/lib/rsyncExec.js +34 -16
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -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:
|
|
@@ -202,3 +213,36 @@ If no agent can be used, or `ssh-add` fails, the wrapper silently falls back to
|
|
|
202
213
|
**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
214
|
|
|
204
215
|
**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.
|
|
216
|
+
|
|
217
|
+
## Running the tests
|
|
218
|
+
|
|
219
|
+
The tests are split into two groups:
|
|
220
|
+
|
|
221
|
+
| command | what runs | needs test hosts |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `npm run test:unit` | `test/unit`: option building, validation, agent handling, ... | no |
|
|
224
|
+
| `npm run test:integration` | `test/integration`: real `ssh` and `rsync` against test hosts | yes |
|
|
225
|
+
| `npm run mocha` | both | yes |
|
|
226
|
+
| `npm test` | both, with coverage | yes |
|
|
227
|
+
|
|
228
|
+
`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.
|
|
229
|
+
|
|
230
|
+
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).
|
|
231
|
+
|
|
232
|
+
### Test hosts
|
|
233
|
+
|
|
234
|
+
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.)
|
|
235
|
+
|
|
236
|
+
To use your own hosts, set these environment variables:
|
|
237
|
+
|
|
238
|
+
| variable | meaning |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `TEST_HOST`, `TEST_PORT`, `TEST_USER` | the test host (required: `TEST_HOST`) |
|
|
241
|
+
| `TEST_PW` or `TEST_KEYFILE` | password or private key of the test user (an ssh-agent also works) |
|
|
242
|
+
| `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`) |
|
|
243
|
+
| `TEST_HOST2_VERIFY`, `TEST_PORT2_VERIFY` | the same second host, **as seen from the machine which runs the tests** (default: `TEST_HOST2`, `TEST_PORT2`) |
|
|
244
|
+
| `TEST_KEEP_FILES` | if set, the files created on the test hosts are not removed |
|
|
245
|
+
|
|
246
|
+
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`).
|
|
247
|
+
|
|
248
|
+
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
|
/**
|
|
@@ -116,19 +125,28 @@ async function isNewRsync(hostInfo) {
|
|
|
116
125
|
*/
|
|
117
126
|
export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableExitCodes, replaceRetryableExitCodes) {
|
|
118
127
|
await connect(hostInfo);
|
|
119
|
-
|
|
128
|
+
//pass each source as its own "host:path" argument. Joining them into one
|
|
129
|
+
//space separated argument relies on the remote shell to split it, which
|
|
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)}`;
|
|
140
|
+
});
|
|
120
141
|
const sshArgs = getSshOption(hostInfo, true);
|
|
121
142
|
const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
|
|
122
|
-
const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, src, dst];
|
|
123
|
-
if (await isNewRsync(hostInfo)) {
|
|
124
|
-
args.unshift("--old-args");
|
|
125
|
-
}
|
|
143
|
+
const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, dst];
|
|
126
144
|
|
|
127
145
|
//make destination directory
|
|
128
146
|
const dstDir = argSrc.len > 1 || hasMagic(argSrc[0]) ? dst : path.dirname(dst);
|
|
129
147
|
await fs.mkdir(dstDir, { recursive: true });
|
|
130
148
|
|
|
131
|
-
debug(`recv ${src} to ${dst}`);
|
|
149
|
+
debug(`recv ${src.join(" ")} to ${dst}`);
|
|
132
150
|
debugVerbose(`recv: ${rsyncCmd} ${args.join(" ")}`);
|
|
133
151
|
const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
|
|
134
152
|
return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-client-wrapper",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
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",
|