Save SSH passwords with connections

This commit is contained in:
jocayn
2026-06-13 13:10:16 +08:00
parent 51b6f9ecfd
commit f5369b7ab9
9 changed files with 102 additions and 22 deletions
+2 -2
View File
@@ -63,8 +63,8 @@ WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
``` ```
Saved connections are stored under the data directory too. WebSSH saves Saved connections are stored under the data directory too. WebSSH saves
hostname, port, username and terminal type for quick reconnects. It does not hostname, port, username, ssh password and terminal type for quick reconnects.
persist ssh passwords, private keys, key passphrases or TOTP codes. It does not persist private keys, key passphrases or TOTP codes.
Use `--auth=false` only for a trusted private deployment where another layer Use `--auth=false` only for a trusted private deployment where another layer
already protects access to WebSSH. already protects access to WebSSH.
+3 -2
View File
@@ -67,8 +67,9 @@ You can also provide credentials with environment variables:
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
Saved connections are stored under the data directory too. WebSSH saves Saved connections are stored under the data directory too. WebSSH saves
hostname, port, username and terminal type for quick reconnects. It does hostname, port, username, ssh password and terminal type for quick
not persist ssh passwords, private keys, key passphrases or TOTP codes. reconnects. It does not persist private keys, key passphrases or TOTP
codes.
Use ``--auth=false`` only for a trusted private deployment where another Use ``--auth=false`` only for a trusted private deployment where another
layer already protects access to WebSSH. layer already protects access to WebSSH.
+27
View File
@@ -318,6 +318,33 @@ class TestWsockHandler(unittest.TestCase):
obj.close.assert_called_with(reason='Worker closed') obj.close.assert_called_with(reason='Worker closed')
class TestIndexHandler(unittest.TestCase): class TestIndexHandler(unittest.TestCase):
def test_get_args_keeps_password_for_saved_connection(self):
obj = Mock(spec=IndexHandler)
obj.get_hostname.return_value = '127.0.0.1'
obj.get_port.return_value = 22
obj.get_value.return_value = 'root'
obj.get_privatekey.return_value = ('', '')
obj.policy = paramiko.WarningPolicy()
obj.ssh_client = Mock()
values = {
'password': 'root-secret',
'passphrase': '',
'totp': '',
'term': 'xterm-256color'
}
obj.get_argument.side_effect = lambda name, default=u'': values.get(
name, default
)
args = IndexHandler.get_args(obj)
self.assertEqual(
('127.0.0.1', 22, 'root', 'root-secret'),
args[:4]
)
self.assertEqual('root-secret', obj.connection_info['password'])
def test_null_in_encoding(self): def test_null_in_encoding(self):
handler = Mock(spec=IndexHandler) handler = Mock(spec=IndexHandler)
+4 -1
View File
@@ -21,11 +21,13 @@ class TestConnectionStore(unittest.TestCase):
'hostname': '127.0.0.1', 'hostname': '127.0.0.1',
'port': 22, 'port': 22,
'username': 'root', 'username': 'root',
'password': 'root-secret',
'term': 'xterm-256color', 'term': 'xterm-256color',
'auth_type': 'password' 'auth_type': 'password'
}) })
self.assertEqual(profile['title'], 'root@127.0.0.1:22') self.assertEqual(profile['title'], 'root@127.0.0.1:22')
self.assertEqual(profile['password'], 'root-secret')
self.assertEqual([profile], self.store.list('admin')) self.assertEqual([profile], self.store.list('admin'))
self.assertEqual([], self.store.list('other')) self.assertEqual([], self.store.list('other'))
@@ -33,6 +35,7 @@ class TestConnectionStore(unittest.TestCase):
'hostname': '127.0.0.1', 'hostname': '127.0.0.1',
'port': 22, 'port': 22,
'username': 'root', 'username': 'root',
'password': 'new-secret',
'term': 'xterm', 'term': 'xterm',
'auth_type': 'privatekey' 'auth_type': 'privatekey'
}) })
@@ -40,7 +43,7 @@ class TestConnectionStore(unittest.TestCase):
self.assertEqual(profile['id'], updated['id']) self.assertEqual(profile['id'], updated['id'])
self.assertEqual(1, len(self.store.list('admin'))) self.assertEqual(1, len(self.store.list('admin')))
self.assertEqual('xterm', self.store.list('admin')[0]['term']) self.assertEqual('xterm', self.store.list('admin')[0]['term'])
self.assertEqual('new-secret', self.store.list('admin')[0]['password'])
self.assertTrue(self.store.delete('admin', profile['id'])) self.assertTrue(self.store.delete('admin', profile['id']))
self.assertFalse(self.store.delete('admin', profile['id'])) self.assertFalse(self.store.delete('admin', profile['id']))
self.assertEqual([], self.store.list('admin')) self.assertEqual([], self.store.list('admin'))
+1
View File
@@ -551,6 +551,7 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
'hostname': hostname, 'hostname': hostname,
'port': port, 'port': port,
'username': username, 'username': username,
'password': password,
'term': term, 'term': term,
'auth_type': 'privatekey' if privatekey else 'password' 'auth_type': 'privatekey' if privatekey else 'password'
} }
+33 -2
View File
@@ -11,7 +11,6 @@
--danger: #d65a4a; --danger: #d65a4a;
--danger-soft: #fff1ee; --danger-soft: #fff1ee;
--shadow: 0 18px 48px rgba(21, 58, 58, 0.09); --shadow: 0 18px 48px rgba(21, 58, 58, 0.09);
--terminal-safe-bottom: 52px;
} }
* { * {
@@ -153,6 +152,35 @@ a:hover {
font-weight: 650; font-weight: 650;
} }
.field-heading {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.field-heading label {
margin-bottom: 0;
}
.inline-toggle {
display: inline-flex !important;
gap: 6px;
align-items: center;
color: var(--muted) !important;
font-size: 12px !important;
font-weight: 600 !important;
white-space: nowrap;
cursor: pointer;
}
.inline-toggle input {
width: 15px;
height: 15px;
accent-color: var(--brand);
}
.form-control { .form-control {
min-height: 44px; min-height: 44px;
color: var(--text); color: var(--text);
@@ -284,7 +312,10 @@ input[type="file"].form-control {
#terminal.terminal-fullscreen { #terminal.terminal-fullscreen {
position: fixed; position: fixed;
inset: 0 0 var(--terminal-safe-bottom) 0; top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 255; z-index: 255;
overflow: hidden; overflow: hidden;
background: #0f1720; background: #0f1720;
+21 -13
View File
@@ -43,6 +43,7 @@ jQuery(function($){
saved_connections_select = $('#saved-connections'), saved_connections_select = $('#saved-connections'),
load_connection_button = $('#load-connection'), load_connection_button = $('#load-connection'),
delete_connection_button = $('#delete-connection'), delete_connection_button = $('#delete-connection'),
show_password_checkbox = $('#show-password'),
term_type = $('#term'), term_type = $('#term'),
style = {}, style = {},
default_title = 'WebSSH', default_title = 'WebSSH',
@@ -180,6 +181,14 @@ jQuery(function($){
} }
function update_password_visibility() {
$('#password').attr(
'type',
show_password_checkbox.prop('checked') ? 'text' : 'password'
);
}
function apply_saved_connection() { function apply_saved_connection() {
var profile = selected_connection(); var profile = selected_connection();
if (!profile) { if (!profile) {
@@ -189,7 +198,7 @@ jQuery(function($){
$('#hostname').val(profile.hostname); $('#hostname').val(profile.hostname);
$('#port').val(profile.port); $('#port').val(profile.port);
$('#username').val(profile.username); $('#username').val(profile.username);
$('#password').val(''); $('#password').val(profile.password || '');
$('#privatekey').val(''); $('#privatekey').val('');
$('#passphrase').val(''); $('#passphrase').val('');
$('#totp').val(''); $('#totp').val('');
@@ -301,20 +310,11 @@ jQuery(function($){
resize_terminal(term); resize_terminal(term);
} }
function terminal_safe_bottom() {
var value = window.getComputedStyle(document.documentElement)
.getPropertyValue('--terminal-safe-bottom'),
parsed = window.parseInt(value, 10);
return parsed > 0 ? parsed : 0;
}
function terminal_size() { function terminal_size() {
var terminal = document.getElementById('terminal'), var terminal = document.getElementById('terminal'),
rect = terminal.getBoundingClientRect(), rect = terminal.getBoundingClientRect(),
width = rect.width || window.innerWidth, width = rect.width || window.innerWidth,
height = rect.height || (window.innerHeight - terminal_safe_bottom()); height = rect.height || window.innerHeight;
return {'width': width, 'height': height}; return {'width': width, 'height': height};
} }
@@ -330,8 +330,8 @@ jQuery(function($){
} }
var size = terminal_size(), var size = terminal_size(),
cols = Math.max(2, parseInt(size.width / style.width, 10) - 1), cols = Math.max(2, parseInt(size.width / style.width, 10)),
rows = Math.max(1, parseInt(size.height / style.height, 10) - 1); rows = Math.max(1, parseInt(size.height / style.height, 10));
return {'cols': cols, 'rows': rows}; return {'cols': cols, 'rows': rows};
} }
@@ -995,6 +995,10 @@ jQuery(function($){
connect(); connect();
}); });
$(form_id).on('reset', function() {
window.setTimeout(update_password_visibility, 0);
});
load_connection_button.click(function() { load_connection_button.click(function() {
apply_saved_connection(); apply_saved_connection();
}); });
@@ -1007,6 +1011,10 @@ jQuery(function($){
delete_saved_connection(); delete_saved_connection();
}); });
show_password_checkbox.change(function() {
update_password_visibility();
});
function cross_origin_connect(event) function cross_origin_connect(event)
{ {
+4 -1
View File
@@ -75,6 +75,9 @@ class ConnectionStore(object):
term = (connection.get('term') or 'xterm-256color').strip() term = (connection.get('term') or 'xterm-256color').strip()
port = connection.get('port') or DEFAULT_PORT port = connection.get('port') or DEFAULT_PORT
port = int(port) port = int(port)
password = connection.get('password')
if password is None:
password = ''
title = '{}@{}:{}'.format(username, hostname, port) title = '{}@{}:{}'.format(username, hostname, port)
auth_type = connection.get('auth_type') or 'password' auth_type = connection.get('auth_type') or 'password'
@@ -86,6 +89,7 @@ class ConnectionStore(object):
'hostname': hostname, 'hostname': hostname,
'port': port, 'port': port,
'username': username, 'username': username,
'password': password,
'term': term, 'term': term,
'auth_type': auth_type 'auth_type': auth_type
} }
@@ -129,4 +133,3 @@ def _chmod_private(path):
os.chmod(path, 0o600) os.chmod(path, 0o600)
except OSError: except OSError:
pass pass
+6
View File
@@ -81,7 +81,13 @@
name="username" value="" required> name="username" value="" required>
</div> </div>
<div class="field"> <div class="field">
<div class="field-heading">
<label for="password">SSH 密码</label> <label for="password">SSH 密码</label>
<label class="inline-toggle" for="show-password">
<input type="checkbox" id="show-password">
<span>显示</span>
</label>
</div>
<input class="form-control" type="password" id="password" <input class="form-control" type="password" id="password"
name="password" value=""> name="password" value="">
</div> </div>