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
hostname, port, username and terminal type for quick reconnects. It does not
persist ssh passwords, private keys, key passphrases or TOTP codes.
hostname, port, username, ssh password and terminal type for quick reconnects.
It does not persist private keys, key passphrases or TOTP codes.
Use `--auth=false` only for a trusted private deployment where another layer
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
Saved connections are stored under the data directory too. WebSSH saves
hostname, port, username and terminal type for quick reconnects. It does
not persist ssh passwords, private keys, key passphrases or TOTP codes.
hostname, port, username, ssh password and terminal type for quick
reconnects. It does not persist private keys, key passphrases or TOTP
codes.
Use ``--auth=false`` only for a trusted private deployment where another
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')
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):
handler = Mock(spec=IndexHandler)
+4 -1
View File
@@ -21,11 +21,13 @@ class TestConnectionStore(unittest.TestCase):
'hostname': '127.0.0.1',
'port': 22,
'username': 'root',
'password': 'root-secret',
'term': 'xterm-256color',
'auth_type': 'password'
})
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([], self.store.list('other'))
@@ -33,6 +35,7 @@ class TestConnectionStore(unittest.TestCase):
'hostname': '127.0.0.1',
'port': 22,
'username': 'root',
'password': 'new-secret',
'term': 'xterm',
'auth_type': 'privatekey'
})
@@ -40,7 +43,7 @@ class TestConnectionStore(unittest.TestCase):
self.assertEqual(profile['id'], updated['id'])
self.assertEqual(1, len(self.store.list('admin')))
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.assertFalse(self.store.delete('admin', profile['id']))
self.assertEqual([], self.store.list('admin'))
+1
View File
@@ -551,6 +551,7 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
'hostname': hostname,
'port': port,
'username': username,
'password': password,
'term': term,
'auth_type': 'privatekey' if privatekey else 'password'
}
+33 -2
View File
@@ -11,7 +11,6 @@
--danger: #d65a4a;
--danger-soft: #fff1ee;
--shadow: 0 18px 48px rgba(21, 58, 58, 0.09);
--terminal-safe-bottom: 52px;
}
* {
@@ -153,6 +152,35 @@ a:hover {
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 {
min-height: 44px;
color: var(--text);
@@ -284,7 +312,10 @@ input[type="file"].form-control {
#terminal.terminal-fullscreen {
position: fixed;
inset: 0 0 var(--terminal-safe-bottom) 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 255;
overflow: hidden;
background: #0f1720;
+21 -13
View File
@@ -43,6 +43,7 @@ jQuery(function($){
saved_connections_select = $('#saved-connections'),
load_connection_button = $('#load-connection'),
delete_connection_button = $('#delete-connection'),
show_password_checkbox = $('#show-password'),
term_type = $('#term'),
style = {},
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() {
var profile = selected_connection();
if (!profile) {
@@ -189,7 +198,7 @@ jQuery(function($){
$('#hostname').val(profile.hostname);
$('#port').val(profile.port);
$('#username').val(profile.username);
$('#password').val('');
$('#password').val(profile.password || '');
$('#privatekey').val('');
$('#passphrase').val('');
$('#totp').val('');
@@ -301,20 +310,11 @@ jQuery(function($){
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() {
var terminal = document.getElementById('terminal'),
rect = terminal.getBoundingClientRect(),
width = rect.width || window.innerWidth,
height = rect.height || (window.innerHeight - terminal_safe_bottom());
height = rect.height || window.innerHeight;
return {'width': width, 'height': height};
}
@@ -330,8 +330,8 @@ jQuery(function($){
}
var size = terminal_size(),
cols = Math.max(2, parseInt(size.width / style.width, 10) - 1),
rows = Math.max(1, parseInt(size.height / style.height, 10) - 1);
cols = Math.max(2, parseInt(size.width / style.width, 10)),
rows = Math.max(1, parseInt(size.height / style.height, 10));
return {'cols': cols, 'rows': rows};
}
@@ -995,6 +995,10 @@ jQuery(function($){
connect();
});
$(form_id).on('reset', function() {
window.setTimeout(update_password_visibility, 0);
});
load_connection_button.click(function() {
apply_saved_connection();
});
@@ -1007,6 +1011,10 @@ jQuery(function($){
delete_saved_connection();
});
show_password_checkbox.change(function() {
update_password_visibility();
});
function cross_origin_connect(event)
{
+4 -1
View File
@@ -75,6 +75,9 @@ class ConnectionStore(object):
term = (connection.get('term') or 'xterm-256color').strip()
port = connection.get('port') or DEFAULT_PORT
port = int(port)
password = connection.get('password')
if password is None:
password = ''
title = '{}@{}:{}'.format(username, hostname, port)
auth_type = connection.get('auth_type') or 'password'
@@ -86,6 +89,7 @@ class ConnectionStore(object):
'hostname': hostname,
'port': port,
'username': username,
'password': password,
'term': term,
'auth_type': auth_type
}
@@ -129,4 +133,3 @@ def _chmod_private(path):
os.chmod(path, 0o600)
except OSError:
pass
+7 -1
View File
@@ -81,7 +81,13 @@
name="username" value="" required>
</div>
<div class="field">
<label for="password">SSH 密码</label>
<div class="field-heading">
<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"
name="password" value="">
</div>