Added SSH key auth and remember to agent Win-SSH link, #3108
Ylian Saint-Hilaire committed
Sep 7, 2021 at 12:42 UTC
a928d3cadac77db7514b2d8d47f1529436a8679c
3 files changed
+133
-30
apprelays.js
+72
-15
@@ -290,6 +290,34 @@ module.exports.CreateSshRelay = function (parent, db, ws, req, args, domain) {
290
delete obj.ws;
291
};
292
293
+ // Save SSH credentials into device
294
+ function saveSshCredentials() {
295
+ parent.parent.db.Get(obj.cookie.nodeid, function (err, nodes) {
296
+ if ((err != null) || (nodes == null) || (nodes.length != 1)) return;
297
+ const node = nodes[0];
298
+ const changed = (node.ssh == null);
299
+
300
+ // Check if credentials are the same
301
+ //if ((typeof node.ssh == 'object') && (node.ssh.u == obj.username) && (node.ssh.p == obj.password)) return; // TODO
302
+
303
+ // Save the credentials
304
+ if (obj.password != null) {
305
+ node.ssh = { u: obj.username, p: obj.password };
306
+ } else if (obj.privateKey != null) {
307
+ node.ssh = { u: obj.username, k: obj.privateKey, kp: obj.privateKeyPass };
308
+ } else return;
309
+ parent.parent.db.Set(node);
310
+
311
+ // Event node change if needed
312
+ if (changed) {
313
+ // Event the node change
314
+ var event = { etype: 'node', action: 'changenode', nodeid: obj.cookie.nodeid, domain: domain.id, userid: obj.cookie.userid, node: parent.CloneSafeNode(node), msg: "Changed SSH credentials" };
315
+ if (parent.parent.db.changeStream) { event.noact = 1; } // If DB change stream is active, don't use this event to change the node. Another event will come.
316
+ parent.parent.DispatchEvent(parent.CreateMeshDispatchTargets(node.meshid, [obj.cookie.nodeid]), obj, event);
317
+ }
318
+ });
319
+ }
320
+
321
// Decode the authentication cookie
322
obj.cookie = parent.parent.decodeCookie(req.query.auth, parent.parent.loginCookieEncryptionKey);
323
if (obj.cookie == null) { obj.ws.send(JSON.stringify({ action: 'sessionerror' })); obj.close(); return; }
@@ -317,6 +345,9 @@ module.exports.CreateSshRelay = function (parent, db, ws, req, args, domain) {
345
const Client = require('ssh2').Client;
346
obj.sshClient = new Client();
347
obj.sshClient.on('ready', function () { // Authentication was successful.
348
+ // If requested, save the credentials
349
+ if (obj.keep === true) saveSshCredentials();
350
+
351
obj.sshClient.shell(function (err, stream) { // Start a remote shell
352
if (err) { obj.close(); return; }
353
obj.sshShell = stream;
@@ -327,7 +358,8 @@ module.exports.CreateSshRelay = function (parent, db, ws, req, args, domain) {
358
obj.ws.send(JSON.stringify({ action: 'connected' }));
359
});
360
obj.sshClient.on('error', function (err) {
330
- if (err.level == 'client-authentication') { obj.ws.send(JSON.stringify({ action: 'autherror' })); }
361
+ if (err.level == 'client-authentication') { try { obj.ws.send(JSON.stringify({ action: 'autherror' })); } catch (ex) { } }
362
+ if (err.level == 'client-timeout') { try { obj.ws.send(JSON.stringify({ action: 'sessiontimeout' })); } catch (ex) { } }
363
obj.close();
364
});
365
@@ -336,10 +368,10 @@ module.exports.CreateSshRelay = function (parent, db, ws, req, args, domain) {
368
369
// Connect the SSH module to the serial tunnel
370
var connectionOptions = { sock: obj.ser }
339
- if (typeof obj.username == 'string') { connectionOptions.username = obj.username; delete obj.username; }
340
- if (typeof obj.password == 'string') { connectionOptions.password = obj.password; delete obj.password; }
341
- if (typeof obj.privateKey == 'string') { connectionOptions.privateKey = obj.privateKey; delete obj.privateKey; }
342
- if (typeof obj.privateKeyPass == 'string') { connectionOptions.passphrase = obj.privateKeyPass; delete obj.privateKeyPass; }
371
+ if (typeof obj.username == 'string') { connectionOptions.username = obj.username; }
372
+ if (typeof obj.password == 'string') { connectionOptions.password = obj.password; }
373
+ if (typeof obj.privateKey == 'string') { connectionOptions.privateKey = obj.privateKey; }
374
+ if (typeof obj.privateKeyPass == 'string') { connectionOptions.passphrase = obj.privateKeyPass; }
375
try {
376
obj.sshClient.connect(connectionOptions);
377
} catch (ex) {
@@ -376,16 +408,41 @@ module.exports.CreateSshRelay = function (parent, db, ws, req, args, domain) {
408
if (typeof msg.action != 'string') return;
409
switch (msg.action) {
410
case 'connect': {
379
- // Verify inputs
380
- if ((typeof msg.username != 'string') || (typeof msg.password != 'string')) break;
381
- if ((typeof msg.rows != 'number') || (typeof msg.cols != 'number') || (typeof msg.height != 'number') || (typeof msg.width != 'number')) break;
382
-
383
- obj.termSize = msg;
384
- obj.username = msg.username;
385
- obj.password = msg.password;
386
- obj.privateKey = msg.key;
387
- obj.privateKeyPass = msg.keypass;
388
- startRelayConnection();
411
+ if (msg.useexisting) {
412
+ // Check if we have SSH credentials for this device
413
+ parent.parent.db.Get(obj.cookie.nodeid, function (err, nodes) {
414
+ if ((err != null) || (nodes == null) || (nodes.length != 1)) return;
415
+ const node = nodes[0];
416
+ if ((node.ssh == null) || (typeof node.ssh != 'object') || (typeof node.ssh.u != 'string') || ((typeof node.ssh.p != 'string') && (typeof node.ssh.k != 'string'))) {
417
+ // Send a request for SSH authentication
418
+ try { ws.send(JSON.stringify({ action: 'sshauth' })) } catch (ex) { }
419
+ } else {
420
+ // Use our existing credentials
421
+ obj.termSize = msg;
422
+ obj.keep = false;
423
+ obj.username = node.ssh.u;
424
+ if (typeof node.ssh.p == 'string') {
425
+ obj.password = node.ssh.p;
426
+ } else if (typeof node.ssh.k == 'string') {
427
+ obj.privateKey = node.ssh.k;
428
+ obj.privateKeyPass = node.ssh.kp;
429
+ }
430
+ startRelayConnection();
431
+ }
432
+ });
433
+ } else {
434
+ // Verify inputs
435
+ if ((typeof msg.username != 'string') || ((typeof msg.password != 'string') && (typeof msg.key != 'string'))) break;
436
+ if ((typeof msg.rows != 'number') || (typeof msg.cols != 'number') || (typeof msg.height != 'number') || (typeof msg.width != 'number')) break;
437
+
438
+ obj.termSize = msg;
439
+ obj.keep = msg.keep; // If true, keep store credentials on the server if the SSH tunnel connected succesfully.
440
+ obj.username = msg.username;
441
+ obj.password = msg.password;
442
+ obj.privateKey = msg.key;
443
+ obj.privateKeyPass = msg.keypass;
444
+ startRelayConnection();
445
+ }
446
break;
447
}
448
case 'resize': {
views/default.handlebars
+1
-1
@@ -2471,7 +2471,7 @@
2471
}
2472
}
2473
}
2474
- x += addHtmlValue2("Last interfaces update", printDateTime(new Date(message.updateTime)));
2474
+ if (message.updateTime != null) { x += addHtmlValue2("Last interfaces update", printDateTime(new Date(message.updateTime))); }
2475
2476
if (message.netif != null) {
2477
// Old style
views/ssh.handlebars
+60
-14
@@ -92,7 +92,12 @@
92
93
// Update the terminal status and buttons
94
updateState();
95
+ resetTerminal();
96
97
+ connectButton();
98
+ }
99
+
100
+ function resetTerminal() {
101
// Setup the terminal with auto-fit
102
if (term != null) { term.dispose(); }
103
if (urlargs.fixsize != 1) { termfit = new FitAddon.FitAddon(); }
@@ -107,8 +112,6 @@
112
resizeTimer = setTimeout(sendResize, 200);
113
});
114
//term.setOption('convertEol', true); // Consider \n to be \r\n, this should be taken care of by "termios"
110
-
111
- connectButton();
115
}
116
117
// Send the new terminal size to the agent
@@ -119,24 +122,47 @@
122
123
function connectButton() {
124
if (state == 0) {
122
- var x = '';
123
- x += addHtmlValue("Username", '<input id=dp2user style=width:230px maxlength=64 autocomplete=off onkeyup=authKeyUp(event) />');
124
- x += addHtmlValue("Password", '<input type=password id=dp2pass style=width:230px maxlength=64 autocomplete=off onkeyup=authKeyUp(event) />');
125
- setDialogMode(2, "Authentication", 3, connectEx, x);
126
- Q('dp2user').value = user;
127
- Q('dp2pass').value = pass;
128
- if (user == '') { Q('dp2user').focus(); } else { Q('dp2pass').focus(); }
129
- setTimeout(authKeyUp, 50);
125
+ connectEx2({ action: 'connect', cols: term.cols, rows: term.rows, width: Q('terminal').offsetWidth, height: Q('terminal').offsetHeight, useexisting: true });
126
} else {
127
disconnect();
128
}
129
}
130
135
- function authKeyUp(e) { QE('idx_dlgOkButton', (Q('dp2user').value.length > 0) && (Q('dp2pass').value.length > 0)); }
131
+ function sshAuthUpdate(e) {
132
+ QV('d2passauth', Q('dp2authmethod').value == 1);
133
+ QV('d2keyauth', Q('dp2authmethod').value == 2);
134
+ if (Q('dp2authmethod').value == 1) {
135
+ QE('idx_dlgOkButton', (Q('dp2user').value.length > 0) && (Q('dp2pass').value.length > 0));
136
+ } else {
137
+ QE('idx_dlgOkButton', false);
138
+ var ok = (Q('dp2user').value.length > 0) && (Q('dp2key').files != null) && (Q('dp2key').files.length == 1) && (Q('dp2key').files[0].size < 8000);
139
+ if (ok == true) {
140
+ var reader = new FileReader();
141
+ reader.onload = function (e) {
142
+ var validkey = ((e.target.result.indexOf('-----BEGIN OPENSSH PRIVATE KEY-----') >= 0) && (e.target.result.indexOf('-----END OPENSSH PRIVATE KEY-----') >= 0));
143
+ QE('idx_dlgOkButton', validkey);
144
+ QS('d2badkey')['color'] = validkey ? '#000' : '#F00';
145
+ }
146
+ reader.readAsText(Q('dp2key').files[0]);
147
+ }
148
+ }
149
+ }
150
151
function connectEx() {
138
- user = Q('dp2user').value;
139
- pass = Q('dp2pass').value;
152
+ var cmd = { action: 'connect', cols: term.cols, rows: term.rows, width: Q('terminal').offsetWidth, height: Q('terminal').offsetHeight, username: Q('dp2user').value, keep: Q('dp2keep').checked };
153
+
154
+ if (Q('dp2authmethod').value == 1) {
155
+ cmd.password = Q('dp2pass').value;
156
+ connectEx2(cmd);
157
+ } else {
158
+ cmd.keypass = Q('dp2keypass').value;
159
+ var reader = new FileReader();
160
+ reader.onload = function (e) { cmd.key = e.target.result; connectEx2(cmd); }
161
+ reader.readAsText(Q('dp2key').files[0]);
162
+ }
163
+ }
164
+
165
+ function connectEx2(cmd) {
166
state = 1;
167
var url = window.location.protocol.replace('http', 'ws') + '//' + window.location.host + domainurl + 'sshrelay.ashx?auth=' + cookie + (urlargs.key ? ('&key=' + urlargs.key) : '');
168
socket = new WebSocket(url);
@@ -146,7 +172,7 @@
172
term.reset();
173
174
// Send username and terminal width and height
149
- socket.send(JSON.stringify({ action: 'connect', username: user, password: pass, cols: term.cols, rows: term.rows, width: Q('terminal').offsetWidth, height: Q('terminal').offsetHeight }));
175
+ socket.send(JSON.stringify(cmd));
176
pass = '';
177
}
178
socket.onmessage = function (data) {
@@ -155,8 +181,27 @@
181
var json = JSON.parse(data.data);
182
switch (json.action) {
183
case 'connected': { state = 3; updateState(); term.focus(); break; }
184
+ case 'sshauth': {
185
+ var x = '';
186
+ x += addHtmlValue("Authentication", '<select id=dp2authmethod style=width:230px onchange=sshAuthUpdate(event)><option value=1 selected>' + "Username & Password" + '</option><option value=2>' + "Username and Key" + '</option></select>')
187
+ x += addHtmlValue("Username", '<input id=dp2user style=width:230px maxlength=64 autocomplete=off onkeyup=sshAuthUpdate(event) />');
188
+ x += '<div id=d2passauth>';
189
+ x += addHtmlValue("Password", '<input type=password id=dp2pass style=width:230px maxlength=64 autocomplete=off onkeyup=sshAuthUpdate(event) />');
190
+ x += '</div><div id=d2keyauth style=display:none>';
191
+ x += addHtmlValue("Key File", '<input type=file id=dp2key style=width:230px maxlength=64 autocomplete=off onchange=sshAuthUpdate(event) />' + '<div id=d2badkey style=font-size:x-small>' + "Key file must be in OpenSSH format." + '</div>');
192
+ x += addHtmlValue("Key Password", '<input type=password id=dp2keypass style=width:230px maxlength=64 autocomplete=off onkeyup=sshAuthUpdate(event) />');
193
+ x += '</div>';
194
+ x += addHtmlValue('', '<label><input id=dp2keep type=checkbox>' + "Remember credentials" + '</label>');
195
+ setDialogMode(2, "Authentication", 3, connectEx, x);
196
+ Q('dp2user').value = user;
197
+ Q('dp2pass').value = pass;
198
+ if (user == '') { Q('dp2user').focus(); } else { Q('dp2pass').focus(); }
199
+ setTimeout(sshAuthUpdate, 50);
200
+ break;
201
+ }
202
case 'autherror': { setDialogMode(2, "Authentication", 1, null, "Unable to authenticate."); break; }
203
case 'sessionerror': { setDialogMode(2, "Session", 1, null, "Session expired."); break; }
204
+ case 'sessiontimeout': { setDialogMode(2, "Session", 1, null, "Session timeout."); break; }
205
}
206
} else if (data.data[0] == '~') {
207
term.writeUtf8(data.data.substring(1));
@@ -171,6 +216,7 @@
216
if (socket != null) { socket.close(); socket = null; }
217
state = 0;
218
updateState();
219
+ resetTerminal();
220
}
221
222
function updateState() {