ssh_tunnel_proxy 1.0.0 → 1.1.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/LICENSE +5 -0
- package/README.md +10 -0
- package/lib/index.js +271 -214
- package/lib/keypair_storage.js +59 -39
- package/lib/ngrok_service.js +30 -19
- package/package.json +1 -1
- package/test/test.js +40 -44
package/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
2
|
+
|
|
3
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
4
|
+
|
|
5
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# ssh_tunnel_proxy
|
|
2
|
+
Initiate a ssh reverse tunnel proxy with forwarding ports
|
|
3
|
+
|
|
4
|
+
ssh_tunnel_proxy can function as a stand-alone api or part of an electron_js app. To establish a ssh tunnel proxy a keypair is generated on the client and stored in the system keychain.
|
|
5
|
+
If a ngrok api key is provided the ngrok api endpoint method is invoked to obtain the hostport of
|
|
6
|
+
the tunnel. A list of port forwards is provided to the connect_api function and ports validated to
|
|
7
|
+
restrict connections to system ports on the remote host to a set of pre-defined ports such as http,https.
|
|
8
|
+
After establishing a ssh connection to the remote server, local proxy port forwards are opened. If
|
|
9
|
+
the connection is interrupted then the ssh_connect method will attempt to re-establish the connection.
|
|
10
|
+
|
package/lib/index.js
CHANGED
|
@@ -20,265 +20,322 @@ const net = require('net');
|
|
|
20
20
|
const { EventEmitter } = require('node:events');
|
|
21
21
|
const { Client } = require('electron-ssh2');
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
23
|
+
const KeypairStorage = require('./keypair_storage');
|
|
24
|
+
const NgrokApi = require('./ngrok_service');
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
const sshEmitter = new SSHEmitter();
|
|
29
|
-
|
|
30
|
-
// debugging options
|
|
31
|
-
const debug = false;
|
|
26
|
+
// debug logging options
|
|
27
|
+
const en = true;
|
|
32
28
|
const debug_ssh = false;
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
30
|
+
const keypairStorage = new KeypairStorage();
|
|
31
|
+
|
|
32
|
+
class sshTunnelProxy extends EventEmitter {
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
super();
|
|
36
|
+
this.listener = {};
|
|
37
|
+
this.retries = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// function to close all active sockets for tunnel restart and exception handling
|
|
41
|
+
close_sockets(server_name, proxy_ports) {
|
|
42
|
+
|
|
43
|
+
if (!this.listener[server_name]) return;
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
const
|
|
45
|
+
proxy_ports.forEach(proxy_port => {
|
|
46
|
+
const server = this.listener[server_name][proxy_port];
|
|
47
|
+
if (server && server.listening) {
|
|
48
|
+
en && this.debug('SSH Server :: closing forward:', proxy_port)
|
|
49
|
+
server.close();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ssh client debug function for verbose ssh connection details
|
|
55
|
+
debug_client = (debug_ssh) ? (msg) => { console.log(msg); } : null;
|
|
56
|
+
|
|
57
|
+
// create forward out on socket connection
|
|
58
|
+
setup_ssh_forward(ssh_client, socket, remote_hostname, remote_port) {
|
|
59
|
+
const _this = this;
|
|
60
|
+
|
|
61
|
+
// setup stream pipeline when port forward is ready
|
|
62
|
+
const on_setup_ssh_forward = (err, stream) => {
|
|
63
|
+
|
|
64
|
+
if (err) {
|
|
65
|
+
_this.emit('error', err);
|
|
66
|
+
en && _this.debug('socket forward error:', err);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// pipe the data from the local socket to the remote port and visa versa
|
|
71
|
+
socket.pipe(stream);
|
|
72
|
+
stream.pipe(socket);
|
|
73
|
+
|
|
74
|
+
// if socket ends, close stream and pipes
|
|
75
|
+
socket.on('close', () => {
|
|
76
|
+
stream.end();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// if socket error, emit error and close stream
|
|
80
|
+
socket.on('error', (err) => {
|
|
81
|
+
_this.emit('error', err);
|
|
82
|
+
en && _this.debug('socket on error:', err);
|
|
83
|
+
stream.end();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
51
86
|
|
|
52
|
-
//
|
|
53
|
-
|
|
87
|
+
// create port forward
|
|
88
|
+
ssh_client.forwardOut(
|
|
89
|
+
socket.remoteAddress,
|
|
90
|
+
socket.remotePort,
|
|
91
|
+
remote_hostname,
|
|
92
|
+
remote_port,
|
|
93
|
+
on_setup_ssh_forward
|
|
94
|
+
);
|
|
54
95
|
|
|
55
|
-
// close open sockets on server, otherwise initialize open sockets storage
|
|
56
|
-
if (listener[opts.server_name]) {
|
|
57
|
-
close_sockets(opts.server_name, opts.proxy_ports);
|
|
58
|
-
} else {
|
|
59
|
-
listener[opts.server_name] = {};
|
|
60
96
|
}
|
|
61
97
|
|
|
62
|
-
//
|
|
63
|
-
|
|
98
|
+
// function to setup proxy forwards for ssh tunnel
|
|
99
|
+
on_ssh_client_ready(ssh_client, server_name, proxy_ports, resolve) {
|
|
64
100
|
|
|
65
101
|
var listeners = 0;
|
|
66
|
-
|
|
102
|
+
if (this.listener[server_name]) this.listener[server_name].isConnected = true;
|
|
103
|
+
const _this = this;
|
|
67
104
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
if (debug) console.log('SSH Client :: end');
|
|
71
|
-
close_sockets(opts.server_name, opts.proxy_ports);
|
|
72
|
-
});
|
|
73
|
-
ssh_client.on('close', () => {
|
|
74
|
-
if (debug) console.log('SSH Client :: close');
|
|
75
|
-
close_sockets(opts.server_name, opts.proxy_ports);
|
|
76
|
-
});
|
|
77
|
-
ssh_client.on('error', err => {
|
|
78
|
-
if (debug) console.log('SSH Client :: error :: ' + err);
|
|
79
|
-
sshEmitter.emit('error', err);
|
|
80
|
-
close_sockets(opts.server_name, opts.proxy_ports);
|
|
81
|
-
ssh_retry_connect(opts);
|
|
82
|
-
resolve();
|
|
83
|
-
});
|
|
84
|
-
ssh_client.on('handshake', negotiated => {
|
|
85
|
-
if (debug) console.log('SSH Client :: handshake:', JSON.stringify(negotiated));
|
|
86
|
-
});
|
|
87
|
-
ssh_client.on('banner', (message, language) => {
|
|
88
|
-
if (debug) console.log('SSH Client :: banner:', message);
|
|
89
|
-
});
|
|
105
|
+
// iterate through a list of local forward ports and create a local proxy port
|
|
106
|
+
proxy_ports.forEach(proxy_port => {
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
ssh_client.on('ready', () => {
|
|
108
|
+
const [local_port, remote_hostname, remote_port] = proxy_port.split(':');
|
|
93
109
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
const [local_port, remote_hostname, remote_port] = proxy_port.split(':');
|
|
110
|
+
// create local socket server
|
|
111
|
+
this.listener[server_name][proxy_port] = net.createServer({ keepAlive: true, allowHalfOpen: false }, socket => {
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
if (en) {
|
|
114
|
+
var debug_msg = 'SSH Server :: connection on ' + local_port + ' ' + socket.remotePort;
|
|
115
|
+
_this.emit('debug', debug_msg);
|
|
116
|
+
_this.debug(debug_msg);
|
|
117
|
+
}
|
|
100
118
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
console.log(debug_msg);
|
|
105
|
-
}
|
|
119
|
+
// create a proxy forward between local and remote ports
|
|
120
|
+
_this.setup_ssh_forward(ssh_client, socket, remote_hostname, remote_port);
|
|
121
|
+
});
|
|
106
122
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
socket.remotePort,
|
|
111
|
-
remote_hostname,
|
|
112
|
-
remote_port,
|
|
113
|
-
(err, stream) => {
|
|
114
|
-
if (err) {
|
|
115
|
-
sshEmitter.emit('error', err);
|
|
116
|
-
if (debug) console.log('socket forward error:', err);
|
|
117
|
-
//reject(err);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// pipe the data from the local socket to the remote port and visa versa
|
|
122
|
-
socket.pipe(stream);
|
|
123
|
-
stream.pipe(socket);
|
|
124
|
-
|
|
125
|
-
// if socket ends, close stream and pipes
|
|
126
|
-
socket.on('close', () => {
|
|
127
|
-
stream.end();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// if socket error, emit error and close stream
|
|
131
|
-
socket.on('error', (err) => {
|
|
132
|
-
sshEmitter.emit('error', err);
|
|
133
|
-
if (debug) console.log('socket on error:', err);
|
|
134
|
-
stream.end();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
);
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
// start listening on port
|
|
142
|
-
listener[opts.server_name][proxy_port].listen(local_port, () => {
|
|
123
|
+
// start listening on port
|
|
124
|
+
try {
|
|
125
|
+
_this.listener[server_name][proxy_port].listen(local_port, () => {
|
|
143
126
|
|
|
144
127
|
// emit server listening on port message
|
|
145
128
|
var status_msg = 'SSH Server :: bound on ' + local_port;
|
|
146
|
-
|
|
129
|
+
_this.emit('debug', status_msg);
|
|
147
130
|
|
|
148
131
|
// if all listeners have been successfully established, resolve setup connection
|
|
149
|
-
if (listeners++ >=
|
|
132
|
+
if (listeners++ >= proxy_ports.length - 1) {
|
|
133
|
+
_this.emit('ready', {});
|
|
150
134
|
resolve();
|
|
151
135
|
}
|
|
152
136
|
});
|
|
153
|
-
})
|
|
137
|
+
} catch (err) {
|
|
138
|
+
en && _this.debug('listen err:', err);
|
|
139
|
+
}
|
|
154
140
|
});
|
|
141
|
+
};
|
|
155
142
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
host: opts.host,
|
|
159
|
-
port: opts.port,
|
|
160
|
-
username: opts.username,
|
|
161
|
-
password: opts.password,
|
|
162
|
-
privateKey: opts.private_key,
|
|
163
|
-
debug: debug_client,
|
|
164
|
-
keepaliveInterval: 10000
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
}
|
|
143
|
+
// handle setting up ssh client and proxy forward ports
|
|
144
|
+
do_ssh_connect(opts) {
|
|
168
145
|
|
|
169
|
-
|
|
170
|
-
const validate_port_number = function (port_str, whitelist) {
|
|
171
|
-
if (isNaN(port_str)) return false;
|
|
172
|
-
const port = parseInt(port_str);
|
|
173
|
-
if (port < 1 || port > 65535) return false;
|
|
174
|
-
if (port < 1024 && whitelist[port] === undefined) return false;
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
146
|
+
const _this = this;
|
|
177
147
|
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
const local_ports = [];
|
|
181
|
-
const remote_ports = [];
|
|
182
|
-
proxy_ports.forEach(proxy_port => {
|
|
183
|
-
const [local_port, remote_hostname, remote_port] = proxy_port.split(':');
|
|
184
|
-
if (!validate_port_number(local_port, whitelist)) local_ports.push(local_port);
|
|
185
|
-
if (!validate_port_number(remote_port, whitelist)) remote_ports.push(remote_port);
|
|
186
|
-
});
|
|
187
|
-
if (local_ports.length || remote_ports.length) {
|
|
188
|
-
const err = new Error('Invalid local forward');
|
|
189
|
-
err.info = {
|
|
190
|
-
local_ports: local_ports.join(','),
|
|
191
|
-
remote_ports: remote_ports.join(',')
|
|
192
|
-
};
|
|
193
|
-
throw (err);
|
|
194
|
-
}
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
148
|
+
// create a new ssh client connection with supplied credentials and hostname/port
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
197
150
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
151
|
+
// exit if connection already established, avoid redundant connection on retry
|
|
152
|
+
if (_this.listener[opts.server_name] && listener[opts.server_name].isConnected) reject();
|
|
153
|
+
|
|
154
|
+
// close open sockets on server, otherwise initialize open sockets storage
|
|
155
|
+
if (_this.listener[opts.server_name]) {
|
|
156
|
+
_this.close_sockets(opts.server_name, opts.proxy_ports);
|
|
157
|
+
} else {
|
|
158
|
+
_this.listener[opts.server_name] = {};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ssh_client = new Client();
|
|
162
|
+
|
|
163
|
+
ssh_client.on('ready', () => {
|
|
164
|
+
_this.on_ssh_client_ready(ssh_client, opts.server_name, opts.proxy_ports, resolve);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
ssh_client.on('end', () => {
|
|
168
|
+
en && _this.debug('SSH Client :: end');
|
|
169
|
+
_this.close_sockets(opts.server_name, opts.proxy_ports);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
ssh_client.on('close', () => {
|
|
173
|
+
en && _this.debug('SSH Client :: close');
|
|
174
|
+
_this.close_sockets(opts.server_name, opts.proxy_ports);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
ssh_client.on('error', err => {
|
|
178
|
+
en && _this.debug('SSH Client :: error :: ' + err);
|
|
179
|
+
_this.emit('error', err);
|
|
180
|
+
_this.close_sockets(opts.server_name, opts.proxy_ports);
|
|
181
|
+
if (_this.listener[opts.server_name]) _this.listener[opts.server_name].isConnected = false;
|
|
182
|
+
_this.ssh_retry_connect(opts);
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
ssh_client.on('handshake', negotiated => {
|
|
187
|
+
en && _this.debug('SSH Client :: handshake:', JSON.stringify(negotiated));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
ssh_client.on('greeting', (message, language) => {
|
|
191
|
+
en && _this.debug('SSH Client :: greeting:', message);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// initiate ssh client connection to remote host
|
|
195
|
+
ssh_client.connect({
|
|
196
|
+
host: opts.host,
|
|
197
|
+
port: opts.port,
|
|
198
|
+
username: opts.username,
|
|
199
|
+
password: opts.password,
|
|
200
|
+
privateKey: opts.private_key,
|
|
201
|
+
debug: _this.debug_client,
|
|
202
|
+
keepaliveInterval: 10000
|
|
203
|
+
});
|
|
203
204
|
|
|
204
|
-
// attempt to establish ssh tunnel to server with supplied parameters.
|
|
205
|
-
const ssh_start_tunnel = async (opts) => {
|
|
206
|
-
return new Promise(async (resolve, reject) => {
|
|
207
|
-
|
|
208
|
-
// after successful tunnel setup complete, send events and reset retry counter
|
|
209
|
-
await do_ssh_connect(opts).then(() => {
|
|
210
|
-
const hostport = opts.host + ':' + opts.port;
|
|
211
|
-
const msg = 'SSH connection to ' + opts.server_name + ' established at ' + hostport;
|
|
212
|
-
if (debug) console.log(msg);
|
|
213
|
-
sshEmitter.emit('status', '', 'ready', hostport);
|
|
214
|
-
retries = 0;
|
|
215
|
-
resolve();
|
|
216
|
-
}, (err) => {
|
|
217
|
-
if (debug) console.log('ssh_retry error:', err);
|
|
218
|
-
reject();
|
|
219
205
|
});
|
|
220
|
-
}
|
|
221
|
-
}
|
|
206
|
+
}
|
|
222
207
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
208
|
+
// validate port number - check for nan, out of valid port range, unauthorized system ports
|
|
209
|
+
validate_port_number(port_str, whitelist) {
|
|
210
|
+
if (isNaN(port_str)) return false;
|
|
211
|
+
const port = parseInt(port_str);
|
|
212
|
+
if (port < 1 || port > 65535) return false;
|
|
213
|
+
if (port < 1024 && whitelist[port] === undefined) return false;
|
|
214
|
+
return true;
|
|
229
215
|
}
|
|
230
216
|
|
|
231
|
-
//
|
|
232
|
-
|
|
217
|
+
// validate local forwards for correct format and valid ports
|
|
218
|
+
validate_local_forward(proxy_ports, whitelist) {
|
|
219
|
+
const local_ports = [];
|
|
220
|
+
const remote_ports = [];
|
|
221
|
+
proxy_ports.forEach(proxy_port => {
|
|
222
|
+
const [local_port, remote_hostname, remote_port] = proxy_port.split(':');
|
|
223
|
+
if (!this.validate_port_number(local_port, whitelist)) local_ports.push(local_port);
|
|
224
|
+
if (!this.validate_port_number(remote_port, whitelist)) remote_ports.push(remote_port);
|
|
225
|
+
});
|
|
226
|
+
if (local_ports.length || remote_ports.length) {
|
|
227
|
+
const err = new Error('Invalid local forward');
|
|
233
228
|
|
|
234
|
-
|
|
235
|
-
|
|
229
|
+
en && this.debug('invalid ports found:\n', JSON.stringify(proxy_ports, null, 2),
|
|
230
|
+
'\n', JSON.stringify(whitelist, null, 2));
|
|
236
231
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return
|
|
232
|
+
err.info = {
|
|
233
|
+
local_ports: local_ports.join(','),
|
|
234
|
+
remote_ports: remote_ports.join(',')
|
|
235
|
+
};
|
|
236
|
+
throw (err);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// on network error, attempt to re-establish connection until 10 retries
|
|
242
|
+
ssh_retry_connect = (opts) => {
|
|
243
|
+
if (this.retries++ < 10) setTimeout(this.connect_ssh, 5000, opts);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
246
|
+
// attempt to establish ssh tunnel to server with supplied parameters.
|
|
247
|
+
ssh_start_tunnel = async (opts) => {
|
|
248
|
+
const _this = this;
|
|
249
|
+
return new Promise(async (resolve, reject) => {
|
|
250
|
+
|
|
251
|
+
// after successful tunnel setup complete, send events and reset retry counter
|
|
252
|
+
await _this.do_ssh_connect(opts).then(() => {
|
|
253
|
+
const hostport = opts.host + ':' + opts.port;
|
|
254
|
+
if (en) {
|
|
255
|
+
const msg = 'SSH connection to ' + opts.server_name + ' established at ' + hostport;
|
|
256
|
+
_this.debug(msg);
|
|
257
|
+
}
|
|
258
|
+
_this.emit('status', '', 'ready', hostport);
|
|
259
|
+
_this.retries = 0;
|
|
260
|
+
resolve();
|
|
261
|
+
}, (err) => {
|
|
262
|
+
en && _this.debug('ssh_retry error:', err);
|
|
263
|
+
reject();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
249
266
|
}
|
|
250
267
|
|
|
251
|
-
//
|
|
252
|
-
// if
|
|
253
|
-
|
|
268
|
+
// setup ssh connection parameters and attempt to establish ssh tunnel
|
|
269
|
+
// todo: if opts have changed while service is running, shutdown current service and restart with new opts
|
|
270
|
+
async connectSSH(opts, whitelist) {
|
|
271
|
+
|
|
272
|
+
en && this.debug('connect_ssh:\n', JSON.stringify(opts, null, 2));
|
|
273
|
+
|
|
274
|
+
// make deep copy of opts for modification
|
|
275
|
+
var _opts = JSON.parse(JSON.stringify(opts));
|
|
276
|
+
|
|
277
|
+
// setup whitelist from param or opts in case of error retry
|
|
278
|
+
_opts.whitelist = (whitelist !== undefined) ? whitelist : (_opts.whitelist) ? _opts.whitelist : {};
|
|
279
|
+
|
|
280
|
+
// validate local port forwards, emit error and quit if invalid
|
|
281
|
+
try {
|
|
282
|
+
this.validate_local_forward(_opts.proxy_ports, _opts.whitelist);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.emit('error', err);
|
|
285
|
+
en && this.debug(err);
|
|
286
|
+
return err;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// if password is empty set to undefined
|
|
290
|
+
if (opts.password && !opts.password.length) {
|
|
291
|
+
_opts.password = undefined;
|
|
292
|
+
}
|
|
254
293
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
294
|
+
// retrieve private key from system keychain with supplied service name and server
|
|
295
|
+
// if no key found, authentication is supplied username, password
|
|
296
|
+
_opts.private_key = await keypairStorage.get_keypair(opts.service_name, opts.server_name);
|
|
297
|
+
|
|
298
|
+
// if ngrok api key provided, obtain hostname and hostport from ngrok api
|
|
299
|
+
// if no ngrok tunnel specified, use supplied host and port
|
|
300
|
+
if (_opts.ngrok_api) {
|
|
301
|
+
const _this = this;
|
|
302
|
+
const ngrokApi = new NgrokApi(_opts.ngrok_api);
|
|
303
|
+
var hostport = await ngrokApi.get_hostport()
|
|
304
|
+
.catch((err) => {
|
|
305
|
+
en && _this.debug('ngrok get_hostport connection error');
|
|
306
|
+
if (this.listener[opts.server_name]) this.listener[opts.server_name].isConnected = false;
|
|
307
|
+
_this.ssh_retry_connect(_opts);
|
|
308
|
+
});
|
|
309
|
+
if (hostport && hostport.host) {
|
|
310
|
+
_opts.host = hostport.host;
|
|
311
|
+
_opts.port = hostport.port;
|
|
312
|
+
}
|
|
313
|
+
else return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// initiate ssh tunnel, block until tunnel is established or error
|
|
317
|
+
return await this.ssh_start_tunnel(_opts)
|
|
259
318
|
.catch((err) => {
|
|
260
|
-
|
|
261
|
-
ssh_retry_connect(opts);
|
|
319
|
+
en && this.debug('ssh_start_tunnel catch:' + err);
|
|
262
320
|
});
|
|
263
|
-
if (hostport && hostport.host) {
|
|
264
|
-
_opts.host = hostport.host;
|
|
265
|
-
_opts.port = hostport.port;
|
|
266
|
-
}
|
|
267
|
-
else return null;
|
|
268
321
|
}
|
|
269
322
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
}
|
|
323
|
+
// resume connection, if previously online
|
|
324
|
+
onNetworkOnline() { }
|
|
273
325
|
|
|
274
|
-
//
|
|
275
|
-
|
|
326
|
+
// connection down, shutdown tunnel
|
|
327
|
+
onNetworkOffline() { }
|
|
328
|
+
|
|
329
|
+
generateAndStoreKeypair(...args) {
|
|
330
|
+
keypairStorage.generate_and_store_keypair(...args);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getPublicKey(...args) {
|
|
334
|
+
keypairStorage.get_public_key_from_keychain(...args);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
debug = (...args) => { console.log(...args); }
|
|
276
338
|
|
|
277
|
-
module.exports = {
|
|
278
|
-
connect_ssh: connect_ssh,
|
|
279
|
-
generate_and_store_keypair: keypair_storage.generate_and_store_keypair,
|
|
280
|
-
generate_keypair: keypair_storage.generate_keypair,
|
|
281
|
-
get_public_key: keypair_storage.get_public_key_from_keychain,
|
|
282
|
-
remove_keypair: keypair_storage.remove_keypair,
|
|
283
|
-
get_event_hook: get_event_hook
|
|
284
339
|
}
|
|
340
|
+
|
|
341
|
+
module.exports = sshTunnelProxy;
|
package/lib/keypair_storage.js
CHANGED
|
@@ -14,56 +14,76 @@ License: MIT
|
|
|
14
14
|
const sshpk = require('sshpk');
|
|
15
15
|
const keytar = require('keytar');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
class KeypairStorage {
|
|
18
|
+
constructor() {
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// export function to generate keypair and store under service name and account
|
|
23
|
+
generate_and_store_keypair(service_name, account) {
|
|
24
|
+
const keypair = this.generate_keypair();
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
keytar.setPassword(service_name, account, keypair.private_key).then((result) => {
|
|
27
|
+
resolve(keypair.public_key);
|
|
28
|
+
}, (err) => {
|
|
29
|
+
reject(err);
|
|
30
|
+
});
|
|
25
31
|
});
|
|
26
|
-
}
|
|
27
|
-
}
|
|
32
|
+
}
|
|
28
33
|
|
|
29
|
-
// export function to obtain public key from system's keychain stored under service_name and account
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// export function to obtain public key from system's keychain stored under service_name and account
|
|
35
|
+
get_public_key_from_keychain(service_name, account) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
keytar.getPassword(service_name, account).then((private_key) => {
|
|
38
|
+
var public_key = this.get_public_key_from_private(private_key);
|
|
39
|
+
resolve(public_key);
|
|
40
|
+
}, (err) => {
|
|
41
|
+
reject(err);
|
|
42
|
+
});
|
|
37
43
|
});
|
|
38
|
-
}
|
|
39
|
-
}
|
|
44
|
+
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
get_public_key_from_private(private_key) {
|
|
47
|
+
if (private_key) {
|
|
48
|
+
var key = sshpk.parsePrivateKey(private_key, 'pem');
|
|
49
|
+
if (key) return key.toPublic().toString('ssh');
|
|
50
|
+
else return null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
46
53
|
}
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
54
|
|
|
50
|
-
// generate EdDSA keypair
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// generate EdDSA keypair
|
|
56
|
+
generate_keypair() {
|
|
57
|
+
// generate private key then obtain public key from private
|
|
58
|
+
// note: EdDSA is required for nodejs ssh
|
|
59
|
+
const privateKey = sshpk.generatePrivateKey('ed25519').toString('ssh');
|
|
60
|
+
const publicKey = this.get_public_key_from_private(privateKey);
|
|
61
|
+
return {
|
|
62
|
+
public_key: publicKey,
|
|
63
|
+
private_key: privateKey
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
get_keypair(service, server) {
|
|
67
|
+
return keytar.getPassword(service, server);
|
|
68
|
+
}
|
|
69
|
+
set_keypair(service, server, key) {
|
|
70
|
+
return keytar.setPassword(service, server, key);
|
|
71
|
+
}
|
|
72
|
+
delete_keypair(service, server) {
|
|
73
|
+
return keytar.deletePassword(service, server);
|
|
74
|
+
}
|
|
60
75
|
}
|
|
61
|
-
|
|
76
|
+
module.exports = KeypairStorage;
|
|
77
|
+
/*
|
|
62
78
|
module.exports = {
|
|
63
79
|
store_private_key: keytar.setPassword,
|
|
64
80
|
retrieve_private_key: keytar.getPassword,
|
|
65
81
|
remove_keypair: keytar.deletePassword,
|
|
66
82
|
generate_keypair: generate_keypair,
|
|
67
83
|
generate_and_store_keypair: generate_and_store_keypair,
|
|
68
|
-
get_public_key_from_keychain: get_public_key_from_keychain
|
|
84
|
+
get_public_key_from_keychain: get_public_key_from_keychain,
|
|
85
|
+
get_keypair: get_keypair,
|
|
86
|
+
set_keypair: set_keypair,
|
|
87
|
+
delete_keypair: delete_keypair
|
|
69
88
|
}
|
|
89
|
+
*/
|
package/lib/ngrok_service.js
CHANGED
|
@@ -11,26 +11,37 @@ License: MIT
|
|
|
11
11
|
// get endpoint hostname and hostport from ngrok api
|
|
12
12
|
const { Ngrok } = require('@ngrok/ngrok-api');
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
14
|
+
class NgrokApi {
|
|
15
|
+
|
|
16
|
+
constructor(apiToken) {
|
|
17
|
+
this.ngrok = new Ngrok({ apiToken: apiToken });
|
|
18
|
+
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
get_hostport() {
|
|
21
|
+
const _this = this;
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
_this.ngrok.endpoints.list()
|
|
24
|
+
.then((endpoints) => {
|
|
25
|
+
const hostport_obj = _this.parse_ngrok_hostport(endpoints);
|
|
26
|
+
resolve(hostport_obj);
|
|
27
|
+
}, (err) => {
|
|
28
|
+
reject(err);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// retrieve hostport from api response
|
|
34
|
+
parse_ngrok_hostport(endpoints) {
|
|
35
|
+
if (endpoints[0] && endpoints[0].hostport) {
|
|
36
|
+
const hostport = endpoints[0].hostport.split(':');
|
|
37
|
+
var hostport_obj = {
|
|
38
|
+
host: hostport[0],
|
|
39
|
+
port: hostport[1]
|
|
40
|
+
}
|
|
41
|
+
return hostport_obj;
|
|
42
|
+
} else {
|
|
43
|
+
throw (new Error('get_ngrok_hostport: no endpoints found'));
|
|
27
44
|
}
|
|
28
|
-
} else {
|
|
29
|
-
throw (new Error('get_ngrok_hostport: no endpoints found'));
|
|
30
45
|
}
|
|
31
|
-
return hostport_obj;
|
|
32
46
|
}
|
|
33
|
-
|
|
34
|
-
module.exports = {
|
|
35
|
-
get_hostport: get_ngrok_hostport
|
|
36
|
-
}
|
|
47
|
+
module.exports = NgrokApi;
|
package/package.json
CHANGED
package/test/test.js
CHANGED
|
@@ -4,26 +4,23 @@ add generate and store keys test
|
|
|
4
4
|
add ngrok api test with stored api key in ssh_tunnel_proxy config
|
|
5
5
|
to run ssh tunnel api tests edit config.json and provide api keys for each service
|
|
6
6
|
*/
|
|
7
|
+
|
|
7
8
|
const assert = require('assert');
|
|
8
|
-
const rewire = require('rewire');
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const os = require('os');
|
|
12
11
|
const sinon = require('sinon');
|
|
13
12
|
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
var main = rewire('../lib/index.js');
|
|
19
|
-
var ngrok_service = rewire('../lib/ngrok_service.js');
|
|
20
|
-
var keypair_storage = rewire('../lib/keypair_storage.js');
|
|
13
|
+
const SSHTunnelProxy = require('../lib/index.js');
|
|
14
|
+
const KeypairStorage = require('../lib/keypair_storage.js');
|
|
15
|
+
const NgrokApi = require('../lib/ngrok_service');
|
|
21
16
|
|
|
22
17
|
// get config file containing api keys
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
const homedir = require('os').homedir();
|
|
19
|
+
const opts = JSON.parse(fs.readFileSync(homedir + '/.config/ssh_tunnel_proxy/config.json'), 'utf8');
|
|
25
20
|
|
|
26
|
-
|
|
21
|
+
const sshTunnelProxy = new SSHTunnelProxy();
|
|
22
|
+
const keypairStorage = new KeypairStorage();
|
|
23
|
+
const ngrokApi = new NgrokApi(opts.ngrok_api);
|
|
27
24
|
|
|
28
25
|
const whitelist = {
|
|
29
26
|
80: true,
|
|
@@ -33,30 +30,30 @@ const whitelist = {
|
|
|
33
30
|
|
|
34
31
|
// port value validation tests
|
|
35
32
|
describe('Validate ports', function () {
|
|
36
|
-
const validate_port_number =
|
|
33
|
+
//const validate_port_number = sshTunnelProxy.__get__('validate_port_number');
|
|
37
34
|
it('valid port number 1024', function () {
|
|
38
|
-
assert(validate_port_number(1024, whitelist), 'should be valid');
|
|
35
|
+
assert(sshTunnelProxy.validate_port_number(1024, whitelist), 'should be valid');
|
|
39
36
|
});
|
|
40
37
|
it('valid port number 80', function () {
|
|
41
|
-
assert(validate_port_number(80, whitelist), 'should be valid');
|
|
38
|
+
assert(sshTunnelProxy.validate_port_number(80, whitelist), 'should be valid');
|
|
42
39
|
});
|
|
43
40
|
it('invalid port number -1', function () {
|
|
44
|
-
assert.equal(false, validate_port_number(-1, whitelist), 'should be invalid');
|
|
41
|
+
assert.equal(false, sshTunnelProxy.validate_port_number(-1, whitelist), 'should be invalid');
|
|
45
42
|
});
|
|
46
43
|
it('invalid port number 65536', function () {
|
|
47
|
-
assert.equal(false, validate_port_number(65536, whitelist), 'should be invalid');
|
|
44
|
+
assert.equal(false, sshTunnelProxy.validate_port_number(65536, whitelist), 'should be invalid');
|
|
48
45
|
});
|
|
49
46
|
it('invalid system port number 137', function () {
|
|
50
|
-
assert.equal(false, validate_port_number(137, whitelist), 'should be invalid');
|
|
47
|
+
assert.equal(false, sshTunnelProxy.validate_port_number(137, whitelist), 'should be invalid');
|
|
51
48
|
});
|
|
52
49
|
it('port number nan', function () {
|
|
53
|
-
assert.equal(false, validate_port_number('nan', whitelist), 'should be invalid');
|
|
50
|
+
assert.equal(false, sshTunnelProxy.validate_port_number('nan', whitelist), 'should be invalid');
|
|
54
51
|
});
|
|
55
52
|
});
|
|
56
53
|
|
|
57
54
|
// local forward format and value validation test
|
|
58
55
|
describe('Validate local forwards', function () {
|
|
59
|
-
const validate_local_forward =
|
|
56
|
+
//const validate_local_forward = sshTunnelProxy.__get__('validate_local_forward');
|
|
60
57
|
var local_forward = [
|
|
61
58
|
'8080:127.0.0.1:80',
|
|
62
59
|
'9000:192.168.43.5:9000',
|
|
@@ -66,10 +63,10 @@ describe('Validate local forwards', function () {
|
|
|
66
63
|
'-1:127.0.0.1:65537',
|
|
67
64
|
];
|
|
68
65
|
it('valid local forward to 80 ' + local_forward[0], function () {
|
|
69
|
-
assert(() => { return validate_local_forward([local_forward[0]], whitelist) }, 'should be valid');
|
|
66
|
+
assert(() => { return sshTunnelProxy.validate_local_forward([local_forward[0]], whitelist) }, 'should be valid');
|
|
70
67
|
});
|
|
71
68
|
it('valid local forward to 9000 ' + local_forward[1], function () {
|
|
72
|
-
assert(() => { return validate_local_forward([local_forward[1]], whitelist) }, 'should be valid');
|
|
69
|
+
assert(() => { return sshTunnelProxy.validate_local_forward([local_forward[1]], whitelist) }, 'should be valid');
|
|
73
70
|
});
|
|
74
71
|
it('invalid local forward format ' + local_forward[2], function () {
|
|
75
72
|
const err = {
|
|
@@ -79,7 +76,7 @@ describe('Validate local forwards', function () {
|
|
|
79
76
|
remote_ports: '',
|
|
80
77
|
}
|
|
81
78
|
};
|
|
82
|
-
assert.throws(() => { validate_local_forward([local_forward[2]], whitelist) }, err);
|
|
79
|
+
assert.throws(() => { sshTunnelProxy.validate_local_forward([local_forward[2]], whitelist) }, err);
|
|
83
80
|
});
|
|
84
81
|
it('invalid local forward format ' + local_forward[3], function () {
|
|
85
82
|
const err = {
|
|
@@ -89,7 +86,7 @@ describe('Validate local forwards', function () {
|
|
|
89
86
|
remote_ports: '',
|
|
90
87
|
}
|
|
91
88
|
};
|
|
92
|
-
assert.throws(() => { validate_local_forward([local_forward[3]], whitelist) }, err, 'should be invalid');
|
|
89
|
+
assert.throws(() => { sshTunnelProxy.validate_local_forward([local_forward[3]], whitelist) }, err, 'should be invalid');
|
|
93
90
|
});
|
|
94
91
|
it('invalid local forward system port ' + local_forward[4], function () {
|
|
95
92
|
const err = {
|
|
@@ -99,7 +96,7 @@ describe('Validate local forwards', function () {
|
|
|
99
96
|
remote_ports: '137',
|
|
100
97
|
}
|
|
101
98
|
};
|
|
102
|
-
assert.throws(() => { validate_local_forward([local_forward[4]], whitelist) }, err, 'should be invalid');
|
|
99
|
+
assert.throws(() => { sshTunnelProxy.validate_local_forward([local_forward[4]], whitelist) }, err, 'should be invalid');
|
|
103
100
|
});
|
|
104
101
|
it('invalid local forward port range ' + local_forward[5], function () {
|
|
105
102
|
const err = {
|
|
@@ -109,7 +106,7 @@ describe('Validate local forwards', function () {
|
|
|
109
106
|
remote_ports: '65537',
|
|
110
107
|
}
|
|
111
108
|
};
|
|
112
|
-
assert.throws(() => { validate_local_forward([local_forward[5]], whitelist) }, err, 'should be invalid');
|
|
109
|
+
assert.throws(() => { sshTunnelProxy.validate_local_forward([local_forward[5]], whitelist) }, err, 'should be invalid');
|
|
113
110
|
});
|
|
114
111
|
});
|
|
115
112
|
|
|
@@ -117,7 +114,7 @@ describe('Validate local forwards', function () {
|
|
|
117
114
|
describe('Store and retrieve private key in system keychain', function () {
|
|
118
115
|
const service_name = 'ssh_proxy_tunnel';
|
|
119
116
|
const account = 'test';
|
|
120
|
-
const keypair = generate_keypair();
|
|
117
|
+
const keypair = keypairStorage.generate_keypair();
|
|
121
118
|
it('generated key should contain a private key', function () {
|
|
122
119
|
assert(keypair.private_key, 'should not be empty');
|
|
123
120
|
});
|
|
@@ -125,32 +122,32 @@ describe('Store and retrieve private key in system keychain', function () {
|
|
|
125
122
|
assert(keypair.public_key, 'should not be empty');
|
|
126
123
|
});
|
|
127
124
|
it('should store generated private key', () => {
|
|
128
|
-
return
|
|
125
|
+
return keypairStorage.set_keypair(service_name, account, keypair.private_key).then(result => {
|
|
129
126
|
assert.equal(result, undefined, 'should not return error');
|
|
130
127
|
})
|
|
131
128
|
});
|
|
132
129
|
it('stored private key should match generated key', () => {
|
|
133
|
-
return
|
|
130
|
+
return keypairStorage.get_keypair(service_name, account).then(stored_key => {
|
|
134
131
|
assert.equal(stored_key, keypair.private_key, 'should match');
|
|
135
132
|
});
|
|
136
133
|
});
|
|
137
134
|
it('stored public key should match generated key', () => {
|
|
138
|
-
return get_public_key_from_keychain(service_name, account).then(stored_key => {
|
|
135
|
+
return keypairStorage.get_public_key_from_keychain(service_name, account).then(stored_key => {
|
|
139
136
|
assert.equal(stored_key, keypair.public_key, 'should match');
|
|
140
137
|
});
|
|
141
138
|
});
|
|
142
139
|
it('remove key should return true', () => {
|
|
143
|
-
return
|
|
140
|
+
return keypairStorage.delete_keypair(service_name, account).then(result => {
|
|
144
141
|
assert('should return true');
|
|
145
142
|
});
|
|
146
143
|
});
|
|
147
144
|
it('retrieve stored private key after delete should return null', () => {
|
|
148
|
-
return
|
|
145
|
+
return keypairStorage.get_keypair(service_name, account).then((result) => {
|
|
149
146
|
assert.equal(result, null, 'should return null');
|
|
150
147
|
});
|
|
151
148
|
});
|
|
152
149
|
it('retrieve stored public key after delete should return null', () => {
|
|
153
|
-
return get_public_key_from_keychain(service_name, account).then((result) => {
|
|
150
|
+
return keypairStorage.get_public_key_from_keychain(service_name, account).then((result) => {
|
|
154
151
|
assert.equal(result, null, 'should return null');
|
|
155
152
|
});
|
|
156
153
|
});
|
|
@@ -166,26 +163,25 @@ describe('', function () {
|
|
|
166
163
|
*/
|
|
167
164
|
|
|
168
165
|
describe('Get ngrok hostport', function () {
|
|
169
|
-
const parse_ngrok_hostport = ngrok_service.__get__('parse_ngrok_hostport');
|
|
166
|
+
//const parse_ngrok_hostport = ngrok_service.__get__('parse_ngrok_hostport');
|
|
170
167
|
var test_endpoint = [{
|
|
171
168
|
hostport: '8.tcp.ngrok.io:17632'
|
|
172
169
|
}];
|
|
173
|
-
var result_opts = parse_ngrok_hostport(test_endpoint, opts);
|
|
170
|
+
var result_opts = ngrokApi.parse_ngrok_hostport(test_endpoint, opts);
|
|
174
171
|
it('host should match 8.tcp.ngrok.io', function () {
|
|
175
172
|
assert.equal(result_opts.host, '8.tcp.ngrok.io');
|
|
176
173
|
});
|
|
177
174
|
it('port should match 17632', function () {
|
|
178
175
|
assert.equal(result_opts.port, '17632');
|
|
179
176
|
});
|
|
180
|
-
it('host, port should be obtained from api', ()=> {
|
|
181
|
-
get_hostport(opts)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
});
|
|
187
|
-
|
|
177
|
+
it('host, port should be obtained from api', async () => {
|
|
178
|
+
result = await ngrokApi.get_hostport(opts);
|
|
179
|
+
assert(result.host, 'host should exist');
|
|
180
|
+
assert(result.port, 'port should exist');
|
|
181
|
+
}, (err) => {
|
|
182
|
+
assert.equal(err, null);
|
|
188
183
|
});
|
|
184
|
+
|
|
189
185
|
});
|
|
190
186
|
|
|
191
187
|
/*
|
|
@@ -226,7 +222,7 @@ var test_generate_keypair = function () {
|
|
|
226
222
|
console.log('keypair:');
|
|
227
223
|
console.log(keypair.public_key);
|
|
228
224
|
console.log(keypair.private_key);
|
|
229
|
-
var homedir =
|
|
225
|
+
var homedir = homedir();
|
|
230
226
|
var fname = path.join(homedir, '.ssh', 'zm_id_rsa.pub')
|
|
231
227
|
//fs.writeFileSync(fname, keypair.public_key);
|
|
232
228
|
fname = path.join(homedir, '.ssh', 'zm_id_rsa')
|