ssh-client-wrapper 1.3.0 → 1.4.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.
@@ -3,6 +3,8 @@ on:
3
3
  push:
4
4
  tags-ignore:
5
5
  - 'v*'
6
+ branches:
7
+ - '*'
6
8
  workflow_call:
7
9
  concurrency:
8
10
  group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +20,7 @@ jobs:
18
20
  TEST_USER: testuser
19
21
  TEST_PW: passw0rd
20
22
  TEST_PORT: 10022
23
+ DEBUG: 'ssh*'
21
24
  runs-on: ubuntu-latest
22
25
  strategy:
23
26
  matrix:
package/lib/fork.js CHANGED
@@ -6,52 +6,70 @@ const { spawn } = require("node-pty");
6
6
 
7
7
  const { rePwPrompt, rePhPrompt, reNewHostPrompt, buff2String } = require("./util.js");
8
8
 
9
- const defaultOpt = {
9
+ const nodePtyOpt = {
10
10
  shell: process.platform === "win32" ? process.env.ComSpec : process.env.SHELL,
11
11
  windowsHide: true
12
12
  };
13
13
 
14
- async function fork(hostInfo, cmd, args, argOpt, outputCallback) {
15
- return new Promise((resolve, reject)=>{
16
- const opt = argOpt || defaultOpt;
14
+ function sshLoginCallback(output, ptyProcess, pw, ph) {
15
+ if (reNewHostPrompt.test(output)) {
16
+ debugSendPty("yes");
17
+ ptyProcess.write("yes\n");
18
+ return;
19
+ }
20
+ if (rePwPrompt.test(output)) {
21
+ if (typeof pw === "string") {
22
+ debug("use given password");
23
+ debugSendPty(pw);
24
+ ptyProcess.write(`${pw}\n`);
25
+ return;
26
+ }
27
+ if (typeof pw === "function") {
28
+ debug("call password callback function");
29
+ pw()
30
+ .then((v)=>{
31
+ ptyProcess.write(`${v}\n`);
32
+ });
33
+ return;
34
+ }
35
+ }
36
+ if (rePhPrompt.test(output)) {
37
+ if (typeof ph === "string") {
38
+ debug("use given passphrase");
39
+ debugSendPty(ph);
40
+ ptyProcess.write(`${ph}\n`);
41
+ return;
42
+ }
43
+ if (typeof ph === "function") {
44
+ debug("call passphrase callback function");
45
+ ph()
46
+ .then((v)=>{
47
+ ptyProcess.write(`${v}\n`);
48
+ });
49
+ }
50
+ }
51
+ }
17
52
 
53
+ function getMasterPty(hostInfo) {
54
+ debug("fork pty for master connection");
55
+ const ptyProcess = spawn(nodePtyOpt.shell, [], nodePtyOpt);
56
+ ptyProcess.onData(buff2String.bind(null, debugVerbose));
57
+ ptyProcess.onData((data)=>{
58
+ const output = data.toString();
59
+ sshLoginCallback(output, ptyProcess, hostInfo.password, hostInfo.passphrase);
60
+ });
61
+ return ptyProcess;
62
+ }
63
+
64
+ async function fork(hostInfo, cmd, args, outputCallback) {
65
+ return new Promise((resolve, reject)=>{
18
66
  debug(`cmd=${cmd}: args=${args}`);
19
- const ptyProcess = spawn(cmd, args, opt);
67
+ const ptyProcess = spawn(cmd, args, nodePtyOpt);
20
68
  ptyProcess.onData(buff2String.bind(null, debugVerbose));
21
-
22
69
  ptyProcess.onData((data)=>{
23
70
  const output = data.toString();
71
+ sshLoginCallback(output, ptyProcess, hostInfo.password, hostInfo.passphrase);
24
72
 
25
- if (reNewHostPrompt.test(output)) {
26
- debugSendPty("yes");
27
- ptyProcess.write("yes\n");
28
- }
29
- if (rePwPrompt.test(output)) {
30
- if (typeof hostInfo.password === "string") {
31
- debug("use given password");
32
- debugSendPty(hostInfo.password);
33
- ptyProcess.write(`${hostInfo.password}\n`);
34
- } else if (typeof hostInfo.password === "function") {
35
- debug("call password callback function");
36
- hostInfo.password()
37
- .then((pw)=>{
38
- ptyProcess.write(`${pw}\n`);
39
- });
40
- }
41
- }
42
- if (rePhPrompt.test(output)) {
43
- if (typeof hostInfo.passphrase === "string") {
44
- debug("use given passphrase");
45
- debugSendPty(hostInfo.passphrase);
46
- ptyProcess.write(`${hostInfo.passphrase}\n`);
47
- } else if (typeof hostInfo.passphrase === "function") {
48
- debug("call passphrase callback function");
49
- hostInfo.passphrase()
50
- .then((pw)=>{
51
- ptyProcess.write(`${pw}\n`);
52
- });
53
- }
54
- }
55
73
  if (/kex_exchange_identification: Connection closed by remote host/.test(output)) {
56
74
  ptyProcess.kill();
57
75
  const err = new Error("kex_exchange_identification");
@@ -76,4 +94,7 @@ async function fork(hostInfo, cmd, args, argOpt, outputCallback) {
76
94
  });
77
95
  });
78
96
  }
79
- module.exports = fork;
97
+ module.exports = {
98
+ fork,
99
+ getMasterPty
100
+ };
package/lib/index.js CHANGED
@@ -12,25 +12,35 @@ const { isArrayOfString } = require("./util.js");
12
12
  */
13
13
 
14
14
  /**
15
- * Facade class.
16
- * @constructor
15
+ * host info object which contain all settings for 1 remote host
16
+ * it will be (shallow) copied in constructor.
17
17
  * @param {Object} hostInfo - option object
18
18
  * @param {string} hostInfo.host - destination
19
19
  * @param {string} hostInfo.user - login name
20
20
  * @param {number} hostInfo.port - port number
21
- * @param {string | Function} hostInfo.password - password
22
- * @param {string | Function} hostInfo.passphrase - passphrase
21
+ * @param {string | Function} hostInfo.password - password or function which return password
22
+ * @param {string | Function} hostInfo.passphrase - passphrase or function which return passphrase of private key
23
23
  * @param {string} hostInfo.keyFile - private key filename
24
24
  * @param {boolean} hostInfo.noStrictHostkeyChecking - bypass host key checking if true
25
25
  * @param {Integer} hostInfo.ControlPersist=180 - how long keep master connection after last client connection has been closed (sec).
26
26
  * @param {Integer} hostInfo.ConnectTimeout=60 - timeout used when connectiong to ths SSH server
27
+ * @param {Integer} hostInfo.maxRetry=3 - max number of retry
28
+ * @param {Integer} hostInfo.retryMinTimeout=60 - minimum duration before first retry
29
+ * @param {Integer} hostInfo.retryMaxTimeout=300 - maximum duration between each retry
30
+ * @param {string[]} hostInfo.sshOpt - additional options for ssh
31
+ */
32
+
33
+ /**
34
+ * Facade class.
35
+ * @constructor
27
36
  */
28
37
  class SshClientWrapper {
29
38
  constructor(hostInfo) {
30
39
  if (typeof hostInfo.host !== "string") {
31
40
  return new Error("hostname must be specified!");
32
41
  }
33
- this.hostInfo = hostInfo;
42
+ this.hostInfo = { ...hostInfo };
43
+ this.hostInfo.masterPty = null;
34
44
  }
35
45
 
36
46
  /**
@@ -79,9 +89,9 @@ class SshClientWrapper {
79
89
 
80
90
  /**
81
91
  * Send file or directory and its child to server.
82
- * @param {[string]} src - File or directory name which to be recieve.
92
+ * @param {string[]} src - File or directory name which to be recieve.
83
93
  * @param {string} dst - Destination path.
84
- * @param {[string]} opt - option for rsync
94
+ * @param {string[]} opt - option for rsync
85
95
  * @returns {Promise} - Resolved with undefined when file transfer is done.
86
96
  */
87
97
  async send(src, dst, opt) {
@@ -107,9 +117,9 @@ class SshClientWrapper {
107
117
 
108
118
  /**
109
119
  * Get file or directory and its child from server.
110
- * @param {[string]} src - File or directory name which to be recieve.
120
+ * @param {string[]} src - File or directory name which to be recieve.
111
121
  * @param {string} dst - Destination path.
112
- * @param {[string]} opt - option for rsync
122
+ * @param {string[]} opt - option for rsync
113
123
  * @returns {Promise} - Resolved with undefined when file transfer is done.
114
124
  */
115
125
  async recv(src, dst, opt) {
package/lib/rsyncExec.js CHANGED
@@ -4,8 +4,8 @@ const debug = require("debug")("sshClientWrapper:rsyncExec");
4
4
  const debugVerbose = require("debug")("sshClientWrapper:rsyncExec_verbose");
5
5
  const { glob } = require("glob");
6
6
 
7
- const { retryWrapper, sshExec } = require("./sshExec.js");
8
- const fork = require("./fork.js");
7
+ const { connect, retryWrapper, sshExec } = require("./sshExec.js");
8
+ const { fork } = require("./fork.js");
9
9
 
10
10
  const { sshCmd, rsyncCmd, getSshOption } = require("./util.js");
11
11
 
@@ -20,6 +20,7 @@ const { sshCmd, rsyncCmd, getSshOption } = require("./util.js");
20
20
  * this function execute rsync -avv --copy-unsafe-links src rsync://[user@]host[:port]/dst
21
21
  */
22
22
  async function send(hostInfo, argSrc, dst, rsyncOpt = []) {
23
+ await connect(hostInfo);
23
24
  const promises = await Promise.allSettled(argSrc.map((e)=>{
24
25
  return glob(e);
25
26
  }));
@@ -56,6 +57,7 @@ async function send(hostInfo, argSrc, dst, rsyncOpt = []) {
56
57
  * this function execute rsync -av --copy-unsafe-links rsync://[user@]host[:port]/src dst
57
58
  */
58
59
  async function recv(hostInfo, argSrc, dst, rsyncOpt = []) {
60
+ await connect(hostInfo);
59
61
  const src = `${hostInfo.host}:${argSrc.join(" ")}`;
60
62
  const sshArgs = getSshOption(hostInfo, true);
61
63
  const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
package/lib/sshExec.js CHANGED
@@ -5,7 +5,7 @@ const promiseRetry = require("promise-retry");
5
5
  const debug = require("debug")("sshClientWrapper:sshExec");
6
6
  const debugVerbose = require("debug")("sshClientWrapper:sshExec_verbose");
7
7
  const { sshCmd, getSshOption } = require("./util.js");
8
- const fork = require("./fork.js");
8
+ const { fork, getMasterPty } = require("./fork.js");
9
9
 
10
10
  /**
11
11
  * Execute command on remote host.
@@ -13,13 +13,14 @@ const fork = require("./fork.js");
13
13
  * @param {string} cmd - Cmdline which will be executed on remote host.
14
14
  * @param {Function} outputCallback - Callback routine for stdout and stderr.
15
15
  */
16
- async function sshExec(hostInfo, cmd, outputCallback, opt = null) {
16
+ async function sshExec(hostInfo, cmd, outputCallback) {
17
+ await connect(hostInfo);
17
18
  debug("exec", cmd, "on remote server");
18
- const args = getSshOption(hostInfo);
19
+ const args = getSshOption(hostInfo, false);
19
20
  args.push(cmd);
20
21
  debugVerbose(`exec: ${sshCmd} ${args.join(" ")}`);
21
22
 
22
- return retryWrapper(fork.bind(null, hostInfo, sshCmd, args, opt, outputCallback), hostInfo);
23
+ return retryWrapper(fork.bind(null, hostInfo, sshCmd, args, outputCallback), hostInfo);
23
24
  }
24
25
 
25
26
  /**
@@ -27,7 +28,8 @@ async function sshExec(hostInfo, cmd, outputCallback, opt = null) {
27
28
  * @param {Object} hostInfo - hostinfo object
28
29
  * @param {Integer} timeout - timeout in secconds
29
30
  */
30
- async function canConnect(hostInfo, timeout) {
31
+ async function canConnect(hostInfo, timeout = 60) {
32
+ await connect(hostInfo);
31
33
  debug("check connectibity");
32
34
  const words = crypto.randomUUID();
33
35
  const args = getSshOption(hostInfo);
@@ -35,7 +37,6 @@ async function canConnect(hostInfo, timeout) {
35
37
  args.push(`-oConnectTimeout=${timeout}`);
36
38
  }
37
39
  args.push(`echo ${words}`);
38
-
39
40
  let output = "";
40
41
  const cb = (e)=>{
41
42
  output += e;
@@ -59,7 +60,7 @@ async function canConnect(hostInfo, timeout) {
59
60
  });
60
61
 
61
62
  debugVerbose(`canConnect: ${sshCmd} ${args.join(" ")}`);
62
- const rt = await retryWrapper(fork.bind(null, hostInfo, sshCmd, args, null, cb), hostInfo);
63
+ const rt = await retryWrapper(fork.bind(null, hostInfo, sshCmd, args, cb), hostInfo);
63
64
  ac.abort();
64
65
 
65
66
  if (rt !== 0) {
@@ -74,6 +75,68 @@ async function canConnect(hostInfo, timeout) {
74
75
  return re.test(output);
75
76
  }
76
77
 
78
+ async function existsMaster(hostInfo) {
79
+ const args = getSshOption(hostInfo);
80
+ args.push("-Ocheck");
81
+
82
+ let output = "";
83
+ const cb = (e)=>{
84
+ output += e;
85
+ };
86
+ try {
87
+ debug(`disconnect: ${sshCmd} ${args.join(" ")}`);
88
+ await fork(hostInfo, sshCmd, args, cb);
89
+ } catch (err) {
90
+ if (err !== 255 || !/Control socket connect.*: No such file or directory/.test(output)) {
91
+ throw err;
92
+ }
93
+ }
94
+ return !/Control socket connect.*: No such file or directory/.test(output);
95
+ }
96
+
97
+ /**
98
+ * create master ssh connection
99
+ */
100
+ async function connect(hostInfo) {
101
+ if (await existsMaster(hostInfo)) {
102
+ debugVerbose("master connection exists");
103
+ return;
104
+ }
105
+
106
+ debug(`create master ssh session to ${hostInfo.host}`);
107
+ const pty = hostInfo.masterPty || getMasterPty(hostInfo);
108
+ hostInfo.masterPty = pty;
109
+
110
+ const words = crypto.randomUUID();
111
+ const re = new RegExp(`^${words}`);
112
+
113
+ const args = getSshOption(hostInfo);
114
+ args.push(`echo ${words}`);
115
+ const cmd = `${sshCmd} ${args.join(" ")}\n`;
116
+ pty.write(cmd);
117
+ const p = new Promise((resolve, reject)=>{
118
+ pty.onData((data)=>{
119
+ const output = data.toString();
120
+ if (re.test(output)) {
121
+ debug("call exit on remotehost");
122
+ pty.write("exit\n");
123
+ resolve();
124
+ } else if (/Permission denied \(/.test(output)) {
125
+ reject(new Error("Permission denied"));
126
+ } else if (/Could not resolve hostname/.test(output)) {
127
+ reject(new Error("Could not resolve hostname"));
128
+ } else if (/Bad port/.test(output)) {
129
+ reject(new Error("Bad port"));
130
+ } else if (/Operation|Connection timed out/.test(output)) {
131
+ reject(new Error("Operation timed out"));
132
+ }
133
+ });
134
+ pty.onExit(reject);
135
+ });
136
+ await p;
137
+ debug(`connected to ${hostInfo.host}`);
138
+ }
139
+
77
140
  /**
78
141
  * remove master session
79
142
  * @param {Object} hostInfo - hostinfo object
@@ -88,12 +151,18 @@ async function disconnect(hostInfo) {
88
151
  };
89
152
  try {
90
153
  debug(`disconnect: ${sshCmd} ${args.join(" ")}`);
91
- await fork(hostInfo, sshCmd, args, null, cb);
154
+ await fork(hostInfo, sshCmd, args, cb);
92
155
  } catch (err) {
93
156
  if (err !== 255 || !/Control socket connect.*: No such file or directory/.test(output)) {
94
157
  throw err;
95
158
  }
96
159
  }
160
+
161
+ if (hostInfo.masterPty) {
162
+ debug("exit masterPty");
163
+ hostInfo.masterPty.write("exit \n");
164
+ hostInfo.masterPty = null;
165
+ }
97
166
  debug(`disconnected from ${hostInfo.host}`);
98
167
  }
99
168
 
@@ -102,7 +171,10 @@ async function disconnect(hostInfo) {
102
171
  * @param {Function} func - function to be wrapped
103
172
  */
104
173
  async function retryWrapper(func, hostInfo) {
105
- return promiseRetry({ retries: 3, minTimeout: 5000, maxTimeout: 5000 }, (retry, number)=>{
174
+ const minTimeout = hostInfo.retryMinTimeout * 1000 || 60000;
175
+ const maxTimeout = hostInfo.retryMaxTimeout * 1000 || 300000;
176
+ const retries = hostInfo.maxRetry || 3;
177
+ return promiseRetry({ retries, minTimeout, maxTimeout }, (retry, number)=>{
106
178
  return func()
107
179
  .catch((e)=>{
108
180
  if (e.retryable) {
@@ -118,6 +190,7 @@ async function retryWrapper(func, hostInfo) {
118
190
  module.exports = {
119
191
  sshExec,
120
192
  canConnect,
193
+ connect,
121
194
  disconnect,
122
195
  retryWrapper
123
196
  };
package/lib/util.js CHANGED
@@ -52,25 +52,17 @@ const getSshOption = (hostInfo, withoutDestination)=>{
52
52
  args.push("-oControlPath=~/.ssh/ssh-client-wrapper-%r@%h:%p");
53
53
  args.push(`-oControlPersist=${ControlPersist}`);
54
54
 
55
- if (Number.isInteger(hostInfo.Timeout) && hostInfo.Timeout > 0) {
56
- args.push(`-oConnectTimeout=${hostInfo.Timeout}`);
55
+ if (Number.isInteger(hostInfo.ConnectTimeout) && hostInfo.ConnectTimeout > 0) {
56
+ args.push(`-oConnectTimeout=${hostInfo.ConnectTimeout}`);
57
+ }
58
+ if (Array.isArray(hostInfo.sshOpt)) {
59
+ args.push(...hostInfo.sshOpt.filter((e)=>{
60
+ return typeof e === "string";
61
+ }));
57
62
  }
58
63
  return args;
59
64
  };
60
65
 
61
- /**
62
- * sleep given time
63
- * @param {number} time - duration in msec
64
- */
65
- async function sleep(time) {
66
- return new Promise((resolve)=>{
67
- setTimeout(()=>{
68
- resolve();
69
- }, time);
70
- });
71
- }
72
-
73
-
74
66
  const isArrayOfString = (target)=>{
75
67
  if (!Array.isArray(target)) {
76
68
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-client-wrapper",
3
- "version": "1.3.0",
3
+ "version": "1.4.2",
4
4
  "description": "wrapper library for open ssh client and rsync",
5
5
  "keywords": [
6
6
  "ssh",
package/test/sshExec.js CHANGED
@@ -16,12 +16,12 @@ const sshout = sinon.stub();
16
16
  const { sshExec, canConnect, disconnect } = require("../lib/sshExec.js");
17
17
 
18
18
  //test helpers
19
- const hostInfo = require("./util/hostInfo.js");
19
+ let hostInfo = require("./util/hostInfo.js");
20
20
  const { nonExisting } = require("./util/testFiles.js");
21
21
 
22
22
 
23
23
  describe("test for ssh execution", function() {
24
- this.timeout(10000);//eslint-disable-line no-invalid-this
24
+ this.timeout(20000);//eslint-disable-line no-invalid-this
25
25
  beforeEach(()=>{
26
26
  sshout.reset();
27
27
  });
@@ -58,37 +58,44 @@ describe("test for ssh execution", function() {
58
58
  });
59
59
  });
60
60
  describe("#canConnect", ()=>{
61
- let hostInfo2;
61
+ let hostInfoBak;
62
62
  beforeEach(async ()=>{
63
- hostInfo2 = { ...hostInfo };
64
63
  await disconnect(hostInfo);
64
+ hostInfo.masterPty = null;
65
+ hostInfoBak = { ...hostInfo };
66
+ });
67
+ afterEach(()=>{
68
+ const masterPty = hostInfo.masterPty;
69
+ hostInfo = { ...hostInfoBak };
70
+ hostInfo.masterPty = masterPty;
65
71
  });
66
72
  it("should be resolved with true", async ()=>{
67
73
  expect(await canConnect(hostInfo, 2)).to.be.true;
68
74
  });
69
75
  it("should be rejected if user does not exist", async ()=>{
70
- hostInfo2.user = "xxxx";
71
- return expect(canConnect(hostInfo2, 2)).to.be.rejected;
76
+ hostInfo.user = "xxxx";
77
+ return expect(canConnect(hostInfo, 2)).to.be.rejected;
72
78
  });
73
79
  it("should be rejected if password is wrong", async ()=>{
74
- hostInfo2.password = "xxxx";
75
- return expect(canConnect(hostInfo2, 2)).to.be.rejected;
80
+ hostInfo.password = "xxxx";
81
+ return expect(canConnect(hostInfo, 2)).to.be.rejected;
76
82
  });
77
83
  it("should be rejected if host does not exist", async ()=>{
78
- hostInfo2.host = "foo.bar.example.com";
79
- return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(255);
84
+ hostInfo.host = "foo.bar.example.com";
85
+ return expect(canConnect(hostInfo, 2)).to.be.rejectedWith(255);
80
86
  });
81
87
  it("should be rejected if host(ip address) does not exist", async ()=>{
82
- hostInfo2.host = "192.0.2.1";
83
- return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(255);
88
+ hostInfo.host = "192.0.2.1";
89
+ hostInfo.ConnectTimeout = 8; //please note each test will be timed out in 20 seconds
90
+ return expect(canConnect(hostInfo, 2)).to.be.rejectedWith(255);
84
91
  });
85
92
  it("should be rejected if port number is out of range(-1)", async ()=>{
86
- hostInfo2.port = -1;
87
- return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(255);
93
+ hostInfo.port = -1;
94
+ return expect(canConnect(hostInfo, 2)).to.be.rejectedWith(255);
88
95
  });
89
96
  it("should be rejected if port number is out of range(65536)", async ()=>{
90
- hostInfo2.port = 65536;
91
- return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(255);
97
+ hostInfo.port = 65536;
98
+ return expect(canConnect(hostInfo, 2)).to.be.rejectedWith(255);
92
99
  });
93
100
  });
94
101
  });