From 3580ded915cf89c84fb8c59d71cc2e3e4e2394be Mon Sep 17 00:00:00 2001 From: Jocay <1579649885@qq.com> Date: Mon, 5 Oct 2026 00:58:22 +0800 Subject: [PATCH] feat: add saved SSH keys and terminal workspace tools --- README.rst | 45 +++- tests/test_app.py | 44 +++- tests/test_handler.py | 46 ++++ tests/test_storage.py | 113 ++++++++ tests/test_workspace.py | 177 +++++++++++++ webssh/handler.py | 36 ++- webssh/main.py | 8 + webssh/settings.py | 2 +- webssh/static/css/app.css | 78 +++++- webssh/static/js/main.js | 469 +++++++++------------------------- webssh/static/js/workspace.js | 435 +++++++++++++++++++++++++++++++ webssh/storage.py | 162 +++++++++++- webssh/templates/index.html | 98 ++++++- webssh/worker.py | 5 +- webssh/workspace.py | 261 +++++++++++++++++++ 15 files changed, 1606 insertions(+), 373 deletions(-) create mode 100644 tests/test_workspace.py create mode 100644 webssh/static/js/workspace.js create mode 100644 webssh/workspace.py diff --git a/README.rst b/README.rst index d8e71ef..1565de7 100755 --- a/README.rst +++ b/README.rst @@ -66,10 +66,47 @@ 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, ssh password and terminal type for quick -reconnects. It does not persist private keys, key passphrases or TOTP -codes. +Saved connections are stored under the data directory too. Connection +details include hostname, port, username, terminal type, initial directory, +name, group and notes. Search or filter saved connections by group, and use +the Save notes button to update metadata without reconnecting. + +Saving connection details and saving credentials are separate options. +Passwords and private keys are encrypted using the server cookie secret +only when Save password / private key is selected. Saving the private-key +passphrase requires its own checkbox. Credentials are never returned in +connection listings, and TOTP codes are never saved. After a successful +connection with Save connection enabled, deselecting Save credentials +clears the previously saved credentials for that destination. + +Each terminal tab owns a separate SSH session. New connection opens another +tab; switching or closing a tab does not interrupt other sessions. Reconnect +starts a new SSH session using saved credentials when available. Encrypted +keys without a saved passphrase and two-factor connections require fresh +input. To retain remote processes across disconnects, use tmux on the SSH +server. Reloading the browser closes its live sessions. + +The terminal toolbar provides output search (also Ctrl/Cmd+F), fullscreen, +font size, themes and cursor settings. Terminal preferences and single-line +command bookmarks are stored in the current browser. Inserting a bookmark +does not send Enter; review it and press Enter yourself. + +File management uses the active tab's SSH connection and requires SFTP on +the remote server. Browse directories and upload or download regular files +up to 16 MiB. Uploads exclusively create a new file and never overwrite an +existing filename. A failed upload may leave a partial file. One file +operation at a time is allowed per SSH session; other terminals remain +usable. HTTP requests are limited to 17 MiB to allow multipart overhead. + +Export configuration produces JSON without credentials. Encrypted backup +includes stored credentials protected by an independent backup password +(at least 10 characters). Keep this password to restore on another server; +restored credentials are encrypted using that server's cookie secret. +Import validates the whole batch before writing, accepts at most 500 +connections and merges by host, port and username under the current user. +Configuration imports preserve existing credentials; encrypted backup +imports replace them for matching destinations. Connection imports do not +change active sessions, browser settings or command bookmarks. Use ``--auth=false`` only for a trusted private deployment where another layer already protects access to WebSSH. diff --git a/tests/test_app.py b/tests/test_app.py index cccebd9..906b036 100755 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -852,6 +852,44 @@ class TestAppWithUnknownEncoding(OtherTestBase): class TestAppWithSavedConnections(OtherTestBase): + def test_connection_form_exposes_separate_credential_controls(self): + response = self.fetch('/') + self.assertIn(b'name="save_credentials" value="1">', response.body) + self.assertIn(b'name="save_passphrase" value="1">', response.body) + self.assertIn(b'id="privatekey-hint"', response.body) + + @tornado.testing.gen_test + def test_saved_key_can_connect_without_uploading_again(self): + privatekey = read_file(make_tests_data_path('user_rsa_key')) + body = dict(self.body, password='', privatekey=privatekey, + save='1', save_credentials='1') + response = yield self.async_post('/', body) + data = json.loads(to_str(response.body)) + self.assert_status_none(data) + self.assertTrue(data['profile']['has_privatekey']) + self.assertNotIn('privatekey_enc', data['profile']) + response = yield self.async_get('/connections') + self.assertNotIn(b'privatekey_enc', response.body) + self.assertNotIn(to_str(privatekey).encode('utf-8'), response.body) + response = yield self.async_post('/', dict(self.body, password='')) + self.assert_status_none(json.loads(to_str(response.body))) + response = yield self.async_post( + '/', dict(self.body, password='', username='bar') + ) + self.assert_status_equal( + 'Authentication failed.', json.loads(to_str(response.body)) + ) + + @tornado.testing.gen_test + def test_save_without_credentials_clears_saved_password(self): + body = dict(self.body, save='1', save_credentials='1') + response = yield self.async_post('/', body) + self.assert_status_none(json.loads(to_str(response.body))) + response = yield self.async_post('/', dict(self.body, save='1')) + data = json.loads(to_str(response.body)) + self.assert_status_none(data) + self.assertFalse(data['profile']['has_password']) + def async_get(self, path): return self.get_http_client().fetch( self.get_url(path), headers=self.headers @@ -859,7 +897,7 @@ class TestAppWithSavedConnections(OtherTestBase): @tornado.testing.gen_test def test_saved_password_never_reaches_the_browser(self): - body = dict(self.body, save='1') + body = dict(self.body, save='1', save_credentials='1') response = yield self.async_post('/', body) data = json.loads(to_str(response.body)) self.assert_status_none(data) @@ -880,7 +918,7 @@ class TestAppWithSavedConnections(OtherTestBase): @tornado.testing.gen_test def test_saved_password_is_reused_when_the_field_is_left_empty(self): - body = dict(self.body, save='1') + body = dict(self.body, save='1', save_credentials='1') response = yield self.async_post('/', body) self.assert_status_none(json.loads(to_str(response.body))) @@ -891,7 +929,7 @@ class TestAppWithSavedConnections(OtherTestBase): @tornado.testing.gen_test def test_saved_password_is_not_sent_to_another_destination(self): - body = dict(self.body, save='1') + body = dict(self.body, save='1', save_credentials='1') response = yield self.async_post('/', body) self.assert_status_none(json.loads(to_str(response.body))) diff --git a/tests/test_handler.py b/tests/test_handler.py index 83deb4e..473ba3c 100755 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -160,6 +160,7 @@ class TestPrivateKey(unittest.TestCase): with self.assertRaises(InvalidValueError) as ctx: pk.get_pkey_obj() self.assertIn('wrong passphrase', str(ctx.exception)) + self.assertNotIn('wrongpass', str(ctx.exception)) pk = self.get_pk_obj(fname, password=password) self.assertIsInstance(pk.get_pkey_obj(), klass) @@ -318,12 +319,41 @@ class TestWsockHandler(unittest.TestCase): obj.close.assert_called_with(reason='Worker closed') class TestIndexHandler(unittest.TestCase): + def test_saved_encrypted_key_uses_supplied_or_saved_passphrase(self): + privatekey = read_file( + make_tests_data_path('test_rsa_password.key') + ) + for supplied in ('', 'television'): + 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.get_saved_privatekey.return_value = ( + privatekey, 'television' if not supplied else 'wrong' + ) + obj.get_saved_password.return_value = '' + obj.get_directory.return_value = '' + obj.policy = paramiko.WarningPolicy() + obj.ssh_client = Mock() + values = {'passphrase': supplied, 'save_credentials': '1', + 'save_passphrase': '1'} + obj.get_argument.side_effect = ( + lambda name, default=u'': values.get(name, default) + ) + args = IndexHandler.get_args(obj) + self.assertIsInstance(args[4], paramiko.RSAKey) + self.assertEqual('television', obj.connection_info['passphrase']) + self.assertEqual('privatekey', obj.connection_info['auth_type']) + self.assertTrue(obj.connection_info['save_credentials']) + 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.get_saved_privatekey.return_value = ('', '') obj.get_directory.return_value = '' obj.policy = paramiko.WarningPolicy() obj.ssh_client = Mock() @@ -352,6 +382,7 @@ class TestIndexHandler(unittest.TestCase): obj.get_port.return_value = 22 obj.get_value.return_value = 'root' obj.get_privatekey.return_value = ('', '') + obj.get_saved_privatekey.return_value = ('', '') obj.get_directory.return_value = '/var/www' obj.policy = paramiko.WarningPolicy() obj.ssh_client = Mock() @@ -362,6 +393,21 @@ class TestIndexHandler(unittest.TestCase): self.assertEqual('/var/www', args[6]) self.assertEqual('/var/www', obj.connection_info['directory']) + def test_websocket_cannot_attach_twice_to_same_worker(self): + obj = Mock(spec=WsockHandler) + obj.current_user = 'admin' + obj.get_client_addr.return_value = ('127.0.0.1', 1234) + obj.get_client_key.return_value = 'user:test-double-attach' + obj.get_value.return_value = 'existing' + active_worker = Mock(handler=Mock()) + worker.clients['user:test-double-attach'] = {'existing': active_worker} + try: + WsockHandler.open(obj) + obj.close.assert_called_once_with(reason='Websocket authentication failed.') + active_worker.set_handler.assert_not_called() + finally: + worker.clients.pop('user:test-double-attach', None) + def test_get_directory(self): obj = Mock(spec=IndexHandler) values = {} diff --git a/tests/test_storage.py b/tests/test_storage.py index 07049f1..915b5b4 100755 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -163,6 +163,119 @@ class TestConnectionStore(unittest.TestCase): }) self.assertEqual('', profile['directory']) + def test_privatekey_is_encrypted_and_never_public(self): + profile = self.store.upsert('admin', { + 'hostname': 'example.com', 'username': 'root', + 'privatekey': 'private-key-material', + 'passphrase': 'key-passphrase', 'save_passphrase': True, + 'auth_type': 'privatekey' + }) + raw = self.read_raw() + self.assertNotIn('private-key-material', raw) + self.assertNotIn('key-passphrase', raw) + self.assertTrue(profile['has_privatekey']) + self.assertTrue(profile['has_passphrase']) + for public in (profile, self.store.list('admin')[0]): + for field in ('privatekey', 'privatekey_enc', 'passphrase', + 'passphrase_enc'): + self.assertNotIn(field, public) + self.assertEqual( + ('private-key-material', 'key-passphrase'), + self.store.get_privatekey('admin', profile['id']) + ) + self.assertEqual( + ('', ''), self.store.get_privatekey('other', profile['id']) + ) + other_id = self.store.make_id('admin', 'evil.example', 22, 'root') + self.assertEqual(('', ''), + self.store.get_privatekey('admin', other_id)) + rotated = ConnectionStore(self.filename, 'other-secret') + self.assertEqual(('', ''), + rotated.get_privatekey('admin', profile['id'])) + + def test_credentials_can_be_replaced_and_cleared(self): + connection = { + 'hostname': 'example.com', 'username': 'root', + 'password': 'password-secret', 'privatekey': 'old-key', + 'passphrase': 'key-secret', 'save_passphrase': True + } + profile = self.store.upsert('admin', connection) + connection.update(privatekey='new-key', save_passphrase=False) + self.store.upsert('admin', connection) + self.assertEqual(('new-key', ''), + self.store.get_privatekey('admin', profile['id'])) + connection['save_credentials'] = False + public = self.store.upsert('admin', connection) + self.assertFalse(public['has_password']) + self.assertFalse(public['has_privatekey']) + self.assertFalse(public['has_passphrase']) + self.assertEqual('', self.store.get_password('admin', profile['id'])) + self.assertEqual(('', ''), + self.store.get_privatekey('admin', profile['id'])) + + def test_privatekey_requires_encryption_secret(self): + store = ConnectionStore(self.filename) + with self.assertRaises(ValueError): + store.upsert('admin', { + 'hostname': 'example.com', 'username': 'root', + 'privatekey': 'private-key-material' + }) + self.assertFalse(os.path.exists(self.filename)) + + def test_metadata_update_keeps_credentials_and_owner_boundary(self): + profile = self.store.upsert('admin', { + 'hostname': 'example.com', 'username': 'root', + 'password': 'secret', 'name': 'Production', 'group': 'Servers' + }) + self.assertIsNone(self.store.update_metadata( + 'other', profile['id'], {'name': 'stolen'} + )) + updated = self.store.update_metadata('admin', profile['id'], { + 'name': 'New name', 'group': 'Testing', 'notes': 'A note' + }) + self.assertEqual('New name', updated['name']) + self.assertEqual('secret', self.store.get_password('admin', profile['id'])) + + def test_plain_export_omits_credentials_and_import_preserves_them(self): + profile = self.store.upsert('admin', { + 'hostname': 'example.com', 'username': 'root', 'password': 'secret' + }) + exported = self.store.export_connections('admin') + self.assertNotIn('password', exported[0]) + self.assertNotIn('owner', exported[0]) + exported[0].update(name='Imported', password='injected') + self.store.import_connections('admin', exported) + self.assertEqual('secret', self.store.get_password('admin', profile['id'])) + self.assertEqual('Imported', self.store.list('admin')[0]['name']) + + def test_invalid_import_is_atomic(self): + with self.assertRaises(ValueError): + self.store.import_connections('admin', [ + {'hostname': 'example.com', 'username': 'root', 'port': 22}, + {'hostname': 'example.com', 'username': 'root', 'port': -1} + ]) + self.assertEqual([], self.store.list('admin')) + with self.assertRaises(ValueError): + self.store.import_connections('admin', [ + {'hostname': 'example.com', 'username': 'root', 'port': 0} + ]) + + def test_encrypted_import_reencrypts_for_destination_server(self): + profile = self.store.upsert('admin', { + 'hostname': 'example.com', 'username': 'root', + 'privatekey': 'key-material', 'passphrase': 'key-secret', + 'save_passphrase': True + }) + exported = self.store.export_connections('admin', True) + destination = ConnectionStore(self.filename + '.new', 'new-server') + destination.import_connections('new-owner', exported, True) + new_id = destination.make_id('new-owner', 'example.com', 22, 'root') + self.assertEqual(('key-material', 'key-secret'), + destination.get_privatekey('new-owner', new_id)) + with open(destination.filename, encoding='utf-8') as saved: + self.assertNotIn('key-material', saved.read()) + self.assertEqual([], destination.list('admin')) + class TestSecretBox(unittest.TestCase): diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..985a4f9 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,177 @@ +import io +import json +import shutil +import stat +import tempfile +import threading +import unittest +from unittest.mock import Mock + +from tornado.options import options +from tornado.testing import AsyncHTTPTestCase + +from webssh.main import make_app, make_handlers +from webssh.settings import get_app_settings +from webssh.worker import clients +from webssh.workspace import ( + TRANSFER_LIMIT, decrypt_backup, encrypt_backup, transfer, validate_path +) + + +class TestBackup(unittest.TestCase): + def test_roundtrip_wrong_password_and_tampering(self): + connections = [{'hostname': 'example.com', 'password': 'secret'}] + document = encrypt_backup(connections, 'long-backup-password') + self.assertNotIn('secret', json.dumps(document)) + self.assertEqual(connections, decrypt_backup(document, 'long-backup-password')) + with self.assertRaises(ValueError): + decrypt_backup(document, 'wrong-backup-password') + document['data'] = document['data'][:-4] + 'AAAA' + with self.assertRaises(ValueError): + decrypt_backup(document, 'long-backup-password') + + def test_short_password_is_rejected(self): + with self.assertRaises(ValueError): + encrypt_backup([], 'short') + + +class TestTransfer(unittest.TestCase): + def make_sftp(self): + ssh, sftp = Mock(), Mock() + ssh.open_sftp.return_value = sftp + sftp.normalize.side_effect = lambda path: path + return ssh, sftp + + def test_list_sorts_directories_before_files(self): + ssh, sftp = self.make_sftp() + sftp.listdir_iter.return_value = [ + Mock(filename='file', st_mode=stat.S_IFREG, st_size=10, st_mtime=0), + Mock(filename='dir', st_mode=stat.S_IFDIR, st_size=0, st_mtime=0) + ] + result = transfer(ssh, 'list', '/home') + self.assertEqual(['dir', 'file'], [item['name'] for item in result['entries']]) + sftp.close.assert_called_once() + sftp.get_channel().settimeout.assert_called_once_with(30) + + def test_upload_is_exclusive_and_rejects_path_in_filename(self): + ssh, sftp = self.make_sftp() + remote = Mock() + sftp.open.return_value.__enter__ = Mock(return_value=remote) + sftp.open.return_value.__exit__ = Mock(return_value=False) + transfer(ssh, 'upload', '/home', ('file.txt', b'content')) + sftp.open.assert_called_once_with('/home/file.txt', 'wx') + remote.write.assert_called_once_with(b'content') + for filename in ('../secret', 'a/b', 'a\\b', '..'): + with self.assertRaises(ValueError): + transfer(ssh, 'upload', '/home', (filename, b'content')) + + def test_download_enforces_size_and_regular_file(self): + ssh, sftp = self.make_sftp() + sftp.stat.return_value = Mock(st_mode=stat.S_IFREG, st_size=3) + sftp.open.return_value = io.BytesIO(b'abc') + self.assertEqual(b'abc', transfer(ssh, 'download', '/home/file')) + sftp.stat.return_value.st_size = TRANSFER_LIMIT + 1 + with self.assertRaises(ValueError): + transfer(ssh, 'download', '/home/file') + sftp.stat.return_value = Mock(st_mode=stat.S_IFDIR, st_size=0) + with self.assertRaises(ValueError): + transfer(ssh, 'download', '/home') + + def test_sftp_is_closed_when_upload_fails(self): + ssh, sftp = self.make_sftp() + sftp.open.side_effect = OSError('Already exists') + with self.assertRaises(OSError): + transfer(ssh, 'upload', '/home', ('existing', b'new')) + sftp.close.assert_called_once() + + def test_paths_reject_control_characters(self): + for path in ('a\x00b', 'a\nb', 'x' * 4097): + with self.assertRaises(ValueError): + validate_path(path) + + +class TestWorkspaceAPI(AsyncHTTPTestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + super(TestWorkspaceAPI, self).setUp() + self.store = self._app.settings['connection_store'] + + def tearDown(self): + clients.pop('user:admin', None) + super(TestWorkspaceAPI, self).tearDown() + shutil.rmtree(self.tmpdir) + + def get_app(self): + options.auth = True + options.auth_username = 'admin' + options.auth_password = 'login-password' + options.auth_password_hash = '' + options.auth_session_days = 7 + options.data_dir = self.tmpdir + options.debug = False + options.xsrf = True + options.policy = 'warning' + options.hostfile = options.syshostfile = options.tdstream = '' + options.origin = 'same' + return make_app(make_handlers(self.io_loop, options), get_app_settings(options)) + + def headers(self): + from tornado.web import create_signed_value + cookie = create_signed_value(self._app.settings['cookie_secret'], 'webssh_user', 'admin') + return {'Cookie': 'webssh_user=' + cookie.decode('utf-8') + '; _xsrf=token', + 'X-XSRFToken': 'token', 'Content-Type': 'application/json'} + + def post_json(self, path, data, headers=None): + return self.fetch(path, method='POST', body=json.dumps(data), + headers=headers or self.headers(), follow_redirects=False) + + def test_plain_export_and_metadata_keep_password_private(self): + profile = self.store.upsert('admin', {'hostname': 'example.com', 'username': 'root', 'password': 'secret'}) + response = self.post_json('/connections/' + profile['id'] + '/metadata', {'name': 'Production'}) + self.assertEqual(200, response.code) + self.assertNotIn(b'password_enc', response.body) + response = self.post_json('/connections/export', {}) + self.assertEqual(200, response.code) + self.assertNotIn(b'secret', response.body) + self.assertEqual('Production', json.loads(response.body)['connections'][0]['name']) + + def test_backup_is_encrypted_and_can_restore(self): + self.store.upsert('admin', {'hostname': 'example.com', 'username': 'root', 'privatekey': 'key-material'}) + response = self.post_json('/connections/export', {'credentials': True, 'password': 'long-backup-password'}) + self.assertEqual(200, response.code) + self.assertNotIn(b'key-material', response.body) + document = json.loads(response.body) + response = self.post_json('/connections/import', {'document': document, 'password': 'wrong-password'}) + self.assertEqual(400, response.code) + response = self.post_json('/connections/import', {'document': document, 'password': 'long-backup-password'}) + self.assertEqual(200, response.code) + self.assertNotIn(b'privatekey_enc', response.body) + + def test_private_routes_require_login_and_xsrf_and_same_origin(self): + for path in ('/connections/export', '/connections/import'): + response = self.post_json(path, {}, {'Content-Type': 'application/json', 'Cookie': '_xsrf=token', 'X-XSRFToken': 'token'}) + self.assertEqual(403, response.code) + headers = self.headers() + headers.pop('X-XSRFToken') + self.assertEqual(403, self.post_json('/connections/export', {}, headers).code) + headers = self.headers() + headers['Origin'] = 'https://other.example' + self.assertEqual(403, self.post_json('/connections/export', {}, headers).code) + + def test_file_worker_is_scoped_to_user_and_rejects_busy_sessions(self): + response = self.fetch('/files/not-owned', headers=self.headers()) + self.assertEqual(409, response.code) + worker = Mock(closed=False, handler=Mock(), file_lock=threading.Lock()) + worker.file_lock.acquire() + clients['user:admin'] = {'busy': worker} + response = self.fetch('/files/busy', headers=self.headers()) + self.assertEqual(409, response.code) + worker.file_lock.release() + + def test_workspace_controls_are_present(self): + response = self.fetch('/', headers=self.headers()) + for element in ('session-tabs', 'terminal-search', 'terminal-theme', + 'command-list', 'files-panel', 'import-config', + 'connection-search', 'connection-group'): + self.assertIn(('id="' + element + '"').encode('utf-8'), response.body) + self.assertIn(b'workspace.js', response.body) diff --git a/webssh/handler.py b/webssh/handler.py index 66d19ca..76e952f 100755 --- a/webssh/handler.py +++ b/webssh/handler.py @@ -255,8 +255,7 @@ class PrivateKey(object): logging.error(str(self.last_exception)) msg = 'Invalid key' if self.password: - msg += ' or wrong passphrase "{}" for decrypting it.'.format( - self.password) + msg += ' or wrong passphrase for decrypting it.' raise InvalidValueError(msg) @@ -668,6 +667,11 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): directory = self.get_directory() if not password and not privatekey: + privatekey, saved_passphrase = self.get_saved_privatekey( + hostname, port, username + ) + if privatekey: + passphrase = passphrase or saved_passphrase password = self.get_saved_password(hostname, port, username) if isinstance(self.policy, paramiko.RejectPolicy): @@ -685,14 +689,35 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): 'port': port, 'username': username, 'password': password, + 'privatekey': privatekey, + 'passphrase': passphrase, + 'requires_totp': bool(totp), + 'save_credentials': self.get_argument( + 'save_credentials', u'' + ) == '1', + 'save_passphrase': self.get_argument( + 'save_passphrase', u'' + ) == '1', 'term': term, 'directory': directory, + 'name': self.get_argument('name', u''), + 'group': self.get_argument('group', u''), + 'notes': self.get_argument('notes', u''), 'auth_type': 'privatekey' if privatekey else 'password' } - logging.debug(args[:5]) + logging.debug('SSH destination: %s:%s', hostname, port) return args + def get_saved_privatekey(self, hostname, port, username): + store = self.connection_store + if not store: + return u'', u'' + profile_id = store.make_id( + self.current_user, hostname, port, username + ) + return store.get_privatekey(self.current_user, profile_id) + def parse_encoding(self, data): try: encoding = to_str(data.strip(), 'ascii') @@ -894,8 +919,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): self.close(reason=str(exc)) else: worker = workers.get(worker_id) - if worker: - workers[worker_id] = None + if worker and not worker.handler: self.set_nodelay(True) worker.set_handler(self) self.worker_ref = weakref.ref(worker) @@ -904,7 +928,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): self.close(reason='Websocket authentication failed.') def on_message(self, message): - logging.debug('{!r} from {}:{}'.format(message, *self.src_addr)) + logging.debug('Websocket message from %s:%s', *self.src_addr) worker = self.worker_ref() if not worker: # The worker has likely been closed. Do not process. diff --git a/webssh/main.py b/webssh/main.py index 261df4d..810ec26 100755 --- a/webssh/main.py +++ b/webssh/main.py @@ -12,6 +12,10 @@ from webssh.settings import ( get_app_settings, get_host_keys_settings, get_policy_setting, get_ssl_context, get_server_settings, check_encoding_setting ) +from webssh.workspace import ( + ConnectionMetadataHandler, ConnectionExportHandler, + ConnectionImportHandler, FilesHandler +) def make_handlers(loop, options): @@ -22,7 +26,11 @@ def make_handlers(loop, options): (r'/login', LoginHandler), (r'/logout', LogoutHandler), (r'/connections', ConnectionsHandler), + (r'/connections/export', ConnectionExportHandler), + (r'/connections/import', ConnectionImportHandler), + (r'/connections/([a-f0-9]{24})/metadata', ConnectionMetadataHandler), (r'/connections/([a-f0-9]{24})', ConnectionHandler), + (r'/files/([A-Za-z0-9_-]+)', FilesHandler), (r'/', IndexHandler, dict(loop=loop, policy=policy, host_keys_settings=host_keys_settings)), (r'/ws', WsockHandler, dict(loop=loop)) diff --git a/webssh/settings.py b/webssh/settings.py index 41c3883..c99af78 100755 --- a/webssh/settings.py +++ b/webssh/settings.py @@ -78,7 +78,7 @@ define('version', type=bool, help='Show version information', base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) font_dirs = ['webssh', 'static', 'css', 'fonts'] -max_body_size = 1 * 1024 * 1024 +max_body_size = 17 * 1024 * 1024 class Font(object): diff --git a/webssh/static/css/app.css b/webssh/static/css/app.css index f538a3a..af3057a 100755 --- a/webssh/static/css/app.css +++ b/webssh/static/css/app.css @@ -216,9 +216,85 @@ input[type="file"].form-control { .save-field { display: flex; - align-items: end; + flex-direction: column; + align-items: flex-start; } +[hidden] { display: none !important; } +.password-overlay { + position: fixed; + inset: 0; + z-index: 400; + display: grid; + place-items: center; + background: rgba(15, 23, 32, 0.55); +} +.password-card { + width: min(420px, calc(100% - 32px)); + padding: 24px; + background: var(--surface); + border-radius: 8px; + box-shadow: var(--shadow); +} + +#connection-search, #connection-group { margin-bottom: 10px; } + +.workspace-toolbar, #session-tabs, .tool-panel { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px; + background: var(--surface-soft); + border: 1px solid var(--line); +} + +#session-tabs { flex-wrap: nowrap; overflow-x: auto; } +.workspace-toolbar button, #session-tabs button, .tool-panel button { + padding: 8px 12px; + min-height: 38px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + cursor: pointer; +} +#session-tabs button { flex-shrink: 0; } +#session-tabs button[aria-selected="true"] { + color: white; + background: var(--brand-dark); +} +.tool-panel input:not([type="checkbox"]), .tool-panel select, .tool-panel textarea { + max-width: 100%; + min-height: 38px; + padding: 7px; + border: 1px solid var(--line); + border-radius: 6px; +} +.tool-panel label { margin: 0; } +.tool-panel small { flex-basis: 100%; color: var(--muted); } +#command-text { flex: 1; min-width: 200px; } +#file-list { flex-basis: 100%; max-height: 250px; overflow: auto; } +#file-list .file-entry { display: block; width: 100%; text-align: left; overflow-wrap: anywhere; } +.session-terminal { height: 65vh; min-height: 260px; padding: 8px; background: #0f1720; } +.session-terminal .xterm { height: 100%; } +#workspace { padding-bottom: 60px; } +#workspace.workspace-fullscreen { + position: fixed; + inset: 0 0 46px; + inset: 0 0 calc(46px + env(safe-area-inset-bottom)); + z-index: 255; + width: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + background: var(--surface); +} +.workspace-fullscreen #session-tools { flex: 0 0 auto; max-height: 55%; overflow: auto; } +.workspace-fullscreen #terminal { flex: 1; min-height: 0; } +.workspace-fullscreen .session-terminal { height: 100%; min-height: 0; } + .save-toggle { display: inline-flex; gap: 10px; diff --git a/webssh/static/js/main.js b/webssh/static/js/main.js index 3fc53ef..6c51446 100755 --- a/webssh/static/js/main.js +++ b/webssh/static/js/main.js @@ -71,6 +71,7 @@ jQuery(function($){ saved_connections = [], saved_connection_map = {}, validated_form_data, + pending_connection, event_origin, hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/; @@ -116,6 +117,13 @@ jQuery(function($){ profile = saved_connections[i]; saved_connection_map[profile.id] = profile; } + var group = $('#connection-group').val(), groups = []; + $('#connection-group').empty().append($('