ssh-client-wrapper 2.0.1 → 2.1.1
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/.dockerignore +1 -0
- package/.github/workflows/coverage_report.yml +2 -0
- package/Dockerfile +10 -0
- package/docker-compose.yml +17 -0
- package/lib/fork.js +16 -11
- package/lib/index.js +13 -0
- package/lib/rsyncExec.js +32 -1
- package/lib/sshExec.js +23 -17
- package/package.json +2 -2
- package/test/interface.js +1 -1
- package/test/rsyncExec.js +29 -1
- package/test/sshExec.js +1 -1
package/.dockerignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
node_modules
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
#build WHEEL client code
|
|
3
|
+
FROM --platform=linux/amd64 node:hydrogen-bookworm-slim
|
|
4
|
+
#FROM --platform=linux/amd64 node:hydrogen-bullseye-slim
|
|
5
|
+
#FROM --platform=linux/amd64 node:hydrogen-buster-slim
|
|
6
|
+
|
|
7
|
+
WORKDIR /work
|
|
8
|
+
RUN apt-get update && apt -y install ssh rsync bzip2 python3 g++ build-essential
|
|
9
|
+
COPY ./ ssh-client-wrapper
|
|
10
|
+
RUN cd ssh-client-wrapper && npm install
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
version: "3.8"
|
|
2
|
+
services:
|
|
3
|
+
test-bed:
|
|
4
|
+
image: naoso5/test-sshd
|
|
5
|
+
ports:
|
|
6
|
+
- 4000:22
|
|
7
|
+
container_name: testbed
|
|
8
|
+
tester:
|
|
9
|
+
build:
|
|
10
|
+
context: .
|
|
11
|
+
dockerfile: Dockerfile
|
|
12
|
+
environment:
|
|
13
|
+
TEST_USER: testuser
|
|
14
|
+
TEST_PW: passw0rd
|
|
15
|
+
TEST_HOST: testbed
|
|
16
|
+
working_dir: /work/ssh-client-wrapper
|
|
17
|
+
command: bash -c "mkdir /root/.ssh 2>/dev/null; npm run test"
|
package/lib/fork.js
CHANGED
|
@@ -75,21 +75,26 @@ async function fork(hostInfo, cmd, args, timeout, outputCallback) {
|
|
|
75
75
|
if (timeout > 0) {
|
|
76
76
|
ac = watchDogTimer(timeout, cmd, ()=>{
|
|
77
77
|
ptyProcess.kill();
|
|
78
|
-
|
|
78
|
+
const err = new Error(`watchdog timer expired ${timeout}`);
|
|
79
|
+
err.code = "TIMEEXPIRE";
|
|
80
|
+
reject(err);
|
|
79
81
|
});
|
|
80
82
|
}
|
|
81
83
|
ptyProcess.onData(buff2String.bind(null, debugVerbose));
|
|
82
|
-
ptyProcess.onData((data)=>{
|
|
83
|
-
const output = data.toString();
|
|
84
|
-
sshLoginCallback(output, ptyProcess, hostInfo.password, hostInfo.passphrase);
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
if (hostInfo !== null) {
|
|
86
|
+
ptyProcess.onData((data)=>{
|
|
87
|
+
const output = data.toString();
|
|
88
|
+
sshLoginCallback(output, ptyProcess, hostInfo.password, hostInfo.passphrase);
|
|
89
|
+
|
|
90
|
+
if (/kex_exchange_identification: Connection closed by remote host/.test(output)) {
|
|
91
|
+
ptyProcess.kill();
|
|
92
|
+
const err = new Error("kex_exchange_identification");
|
|
93
|
+
err.retryable = true;
|
|
94
|
+
reject(err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
93
98
|
|
|
94
99
|
if (typeof outputCallback === "function") {
|
|
95
100
|
ptyProcess.onData(buff2String.bind(null, outputCallback));
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
2
4
|
const debug = require("debug")("sshClientWrapper:interface");
|
|
3
5
|
const debugVerbose = require("debug")("sshClientWrapper:verbose:interface");
|
|
4
6
|
const { sshExec, canConnect, disconnect, ls } = require("./sshExec.js");
|
|
@@ -46,6 +48,17 @@ class SshClientWrapper {
|
|
|
46
48
|
sanityCheck(hostInfo);
|
|
47
49
|
this.hostInfo = { ...hostInfo };
|
|
48
50
|
this.hostInfo.masterPty = null;
|
|
51
|
+
this.hostInfo.rsyncVersion = null;
|
|
52
|
+
|
|
53
|
+
if (process.env.HOME) {
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.resolve(process.env.HOME, ".ssh"));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e.code !== "EEXIST") {
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/**
|
package/lib/rsyncExec.js
CHANGED
|
@@ -9,6 +9,19 @@ const { fork } = require("./fork.js");
|
|
|
9
9
|
|
|
10
10
|
const { sshCmd, rsyncCmd, getSshOption } = require("./util.js");
|
|
11
11
|
|
|
12
|
+
async function checkRsyncVersion(timeout) {
|
|
13
|
+
const rt = {};
|
|
14
|
+
await fork(null, rsyncCmd, ["--version"], timeout, (output)=>{
|
|
15
|
+
const result = /^rsync *version *(\d+)\.(\d+)\.(\d+).*$/m.exec(output);
|
|
16
|
+
|
|
17
|
+
if (result) {
|
|
18
|
+
rt.major = Number(result[1]);
|
|
19
|
+
rt.minor = Number(result[2]);
|
|
20
|
+
rt.patch = Number(result[3]);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return rt;
|
|
24
|
+
}
|
|
12
25
|
|
|
13
26
|
/**
|
|
14
27
|
* Send file or directory and its child to server.
|
|
@@ -69,6 +82,21 @@ async function send(hostInfo, argSrc, dst, rsyncOpt, timeout) {
|
|
|
69
82
|
return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout), hostInfo);
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
/**
|
|
86
|
+
* format src array to src string for recv
|
|
87
|
+
* @param {Object} hostInfo - hostinfo object
|
|
88
|
+
* @returns {boolean} - rsync command version is higher than 3.2.4 or not
|
|
89
|
+
*/
|
|
90
|
+
async function isNewRsync(hostInfo) {
|
|
91
|
+
if (!hostInfo.rsyncVersion ||
|
|
92
|
+
!Number.isInteger(hostInfo.rsyncVersion.major) ||
|
|
93
|
+
!Number.isInteger(hostInfo.rsyncVersion.minor) ||
|
|
94
|
+
!Number.isInteger(hostInfo.rsyncVersion.patch)) {
|
|
95
|
+
hostInfo.rsyncVersion = await checkRsyncVersion(3);
|
|
96
|
+
}
|
|
97
|
+
return hostInfo.rsyncVersion.major >= 3 && hostInfo.rsyncVersion.minor >= 2 && hostInfo.rsyncVersion.patch >= 4;
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
/**
|
|
73
101
|
* Send file or directory and its child to server.
|
|
74
102
|
* @param {Object} hostInfo - hostinfo object
|
|
@@ -84,14 +112,17 @@ async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout) {
|
|
|
84
112
|
const src = `${hostInfo.host}:${argSrc.join(" ")}`;
|
|
85
113
|
const sshArgs = getSshOption(hostInfo, true);
|
|
86
114
|
const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
|
|
87
|
-
|
|
88
115
|
const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, src, dst];
|
|
116
|
+
if (await isNewRsync(hostInfo)) {
|
|
117
|
+
args.unshift("--old-args");
|
|
118
|
+
}
|
|
89
119
|
|
|
90
120
|
debug(`recv ${src} to ${dst}`);
|
|
91
121
|
debugVerbose(`recv: ${rsyncCmd} ${args.join(" ")}`);
|
|
92
122
|
return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout), hostInfo);
|
|
93
123
|
}
|
|
94
124
|
module.exports = {
|
|
125
|
+
checkRsyncVersion,
|
|
95
126
|
send,
|
|
96
127
|
recv
|
|
97
128
|
};
|
package/lib/sshExec.js
CHANGED
|
@@ -64,7 +64,7 @@ async function canConnect(hostInfo, timeout = 60) {
|
|
|
64
64
|
* @param {Object} hostInfo - hostinfo object
|
|
65
65
|
* @returns {boolean} - true if master session exists
|
|
66
66
|
*/
|
|
67
|
-
async function existsMaster(hostInfo) {
|
|
67
|
+
async function existsMaster(hostInfo, argTimeout) {
|
|
68
68
|
debugVerbose("existsMaster called");
|
|
69
69
|
const args = getSshOption(hostInfo);
|
|
70
70
|
args.push("-Ocheck");
|
|
@@ -74,20 +74,22 @@ async function existsMaster(hostInfo) {
|
|
|
74
74
|
output += e;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
const
|
|
78
|
-
const timeout = hostInfo.ConnectTimeout || 60;
|
|
77
|
+
const timeout = argTimeout || hostInfo.ConnectTimeout || 60;
|
|
79
78
|
debug(`check master session: ${sshCmd} ${args.join(" ")}`);
|
|
80
79
|
|
|
81
80
|
try {
|
|
82
81
|
await fork(hostInfo, sshCmd, args, timeout, cb);
|
|
83
82
|
} catch (err) {
|
|
83
|
+
if (err.code === "TIMEEXPIRE") {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
84
86
|
if (err.rt !== 255) {
|
|
85
87
|
debug("check master session failed", err);
|
|
86
88
|
throw err;
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
return
|
|
92
|
+
return !/Control socket connect.*: No such file or directory/.test(output);
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/**
|
|
@@ -95,7 +97,7 @@ async function existsMaster(hostInfo) {
|
|
|
95
97
|
* @param {Object} hostInfo - hostinfo object
|
|
96
98
|
*/
|
|
97
99
|
async function connect(hostInfo, timeout = 60) {
|
|
98
|
-
if (await existsMaster(hostInfo)) {
|
|
100
|
+
if (await existsMaster(hostInfo, timeout)) {
|
|
99
101
|
debugVerbose("master connection exists");
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
@@ -105,20 +107,22 @@ async function connect(hostInfo, timeout = 60) {
|
|
|
105
107
|
|
|
106
108
|
debug(`create master ssh session to ${hostInfo.host}`);
|
|
107
109
|
|
|
108
|
-
const ac = watchDogTimer(timeout, "make connection", ()=>{
|
|
109
|
-
sendPty(hostInfo.masterPty, "\n", debugSendMasterPty, 137);
|
|
110
|
-
sendPty(hostInfo.masterPty, "exit\n", debugSendMasterPty, 138);
|
|
111
|
-
|
|
112
|
-
const err = new Error(`can not connect within ${timeout} sec`);
|
|
113
|
-
err.host = hostInfo.host;
|
|
114
|
-
err.user = hostInfo.user;
|
|
115
|
-
err.port = hostInfo.port;
|
|
116
|
-
throw err;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
110
|
const words = crypto.randomUUID();
|
|
120
111
|
const re = new RegExp(`^${words}`);
|
|
112
|
+
|
|
113
|
+
let ac = null;
|
|
121
114
|
const p = new Promise((resolve, reject)=>{
|
|
115
|
+
ac = watchDogTimer(timeout, "make connection", ()=>{
|
|
116
|
+
sendPty(hostInfo.masterPty, "\n", debugSendMasterPty, 137);
|
|
117
|
+
sendPty(hostInfo.masterPty, "exit\n", debugSendMasterPty, 138);
|
|
118
|
+
|
|
119
|
+
const err = new Error(`can not connect within ${timeout} sec`);
|
|
120
|
+
err.host = hostInfo.host;
|
|
121
|
+
err.user = hostInfo.user;
|
|
122
|
+
err.port = hostInfo.port;
|
|
123
|
+
reject(err);
|
|
124
|
+
});
|
|
125
|
+
|
|
122
126
|
let done = false;
|
|
123
127
|
hostInfo.masterPty.onData((data)=>{
|
|
124
128
|
if (done) {
|
|
@@ -157,7 +161,9 @@ async function connect(hostInfo, timeout = 60) {
|
|
|
157
161
|
await p;
|
|
158
162
|
debug(`connected to ${hostInfo.host}`);
|
|
159
163
|
} finally {
|
|
160
|
-
ac
|
|
164
|
+
if (ac !== null) {
|
|
165
|
+
ac.abort();
|
|
166
|
+
}
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-client-wrapper",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "wrapper library for open ssh client and rsync",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ssh",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"debug": "^4.3.4",
|
|
38
|
-
"glob": "^10.3.
|
|
38
|
+
"glob": "^10.3.4",
|
|
39
39
|
"node-pty": "^0.10.1",
|
|
40
40
|
"nyc": "^15.1.0",
|
|
41
41
|
"promise-retry": "^2.0.1",
|
package/test/interface.js
CHANGED
|
@@ -56,7 +56,7 @@ describe("test for interface", ()=>{
|
|
|
56
56
|
describe("test for public method", ()=>{
|
|
57
57
|
let ssh;
|
|
58
58
|
const hostInfoOrg = { host: "hoge" };
|
|
59
|
-
const hostInfo = { ...hostInfoOrg, masterPty: null };
|
|
59
|
+
const hostInfo = { ...hostInfoOrg, masterPty: null, rsyncVersion: null };
|
|
60
60
|
beforeEach(()=>{
|
|
61
61
|
ssh = new SshClientWrapper(hostInfoOrg);
|
|
62
62
|
sshExec.reset();
|
package/test/rsyncExec.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const fs = require("fs-extra");
|
|
3
3
|
const path = require("path");
|
|
4
|
+
const util = require("util");
|
|
5
|
+
const exec = util.promisify(require("node:child_process").exec);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* to enable debug log dynamically
|
|
9
|
+
* (1) enable folloing line
|
|
10
|
+
* const debug = require("debug");
|
|
11
|
+
* (2) insert this line into the beginning of it()
|
|
12
|
+
* debug.enable("ssh*");
|
|
13
|
+
* (3) insert this line into the end of it()
|
|
14
|
+
* debug.disable();
|
|
15
|
+
**/
|
|
4
16
|
|
|
5
17
|
process.on("unhandledRejection", console.dir); //eslint-disable-line no-console
|
|
6
18
|
Error.traceLimit = 100000;
|
|
@@ -12,7 +24,7 @@ chai.use(require("chai-fs"));
|
|
|
12
24
|
chai.use(require("chai-as-promised"));
|
|
13
25
|
|
|
14
26
|
//testee
|
|
15
|
-
const { send, recv } = require("../lib/rsyncExec.js");
|
|
27
|
+
const { checkRsyncVersion, send, recv } = require("../lib/rsyncExec.js");
|
|
16
28
|
|
|
17
29
|
//test helpers
|
|
18
30
|
const { sshExec, disconnect } = require("../lib/sshExec.js");
|
|
@@ -42,6 +54,14 @@ const formatLsOutput = (array)=>{
|
|
|
42
54
|
|
|
43
55
|
|
|
44
56
|
//actual test start from here
|
|
57
|
+
describe("test checkRsyncVersion", async ()=>{
|
|
58
|
+
const { major, minor, patch } = await checkRsyncVersion(1);
|
|
59
|
+
const versionString = `${major}.${minor}.${patch}`;
|
|
60
|
+
console.log(`==== test with rsync version ${versionString} ====`); //eslint-disable-line no-console
|
|
61
|
+
const { stdout } = await exec("rsync --version |grep 'version'");
|
|
62
|
+
expect(stdout).to.match(new RegExp(versionString));
|
|
63
|
+
});
|
|
64
|
+
|
|
45
65
|
describe("test rsync exec", function() {
|
|
46
66
|
this.timeout(10000);//eslint-disable-line no-invalid-this
|
|
47
67
|
const rt = [];
|
|
@@ -370,6 +390,14 @@ describe("test rsync exec", function() {
|
|
|
370
390
|
expect(rt2).to.have.members(["puyo", "poyo"]);
|
|
371
391
|
});
|
|
372
392
|
});
|
|
393
|
+
describe("degradation check", ()=>{
|
|
394
|
+
it("should recieve multiple files from remote machine", async ()=>{
|
|
395
|
+
await recv(hostInfo, [path.posix.join(remoteRoot, "foo"),
|
|
396
|
+
path.posix.join(remoteRoot, "bar"), path.posix.join(remoteRoot, "baz")], localEmptyDir, [], 0);
|
|
397
|
+
const rt2 = await fs.readdir(localEmptyDir);
|
|
398
|
+
expect(rt2).to.have.members(["foo", "bar", "baz"]);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
373
401
|
describe("error case", ()=>{
|
|
374
402
|
it("should not send directory to existing file path", ()=>{
|
|
375
403
|
return expect(recv(hostInfo, [remoteRoot], localFiles[0], [], 0)).to.be.rejected;
|
package/test/sshExec.js
CHANGED
|
@@ -76,7 +76,7 @@ describe("test for ssh execution", function() {
|
|
|
76
76
|
await disconnect(hostInfo2);
|
|
77
77
|
});
|
|
78
78
|
it("should be resolved with true", async ()=>{
|
|
79
|
-
return expect(await canConnect(hostInfo2,
|
|
79
|
+
return expect(await canConnect(hostInfo2, 5)).to.be.true;
|
|
80
80
|
});
|
|
81
81
|
it("should be rejected if user does not exist", async ()=>{
|
|
82
82
|
hostInfo2.user = "xxxx";
|