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($('').val('').text('全部分组')); + saved_connections.forEach(function(profile) { + if (profile.group && groups.indexOf(profile.group) < 0) { groups.push(profile.group); } + }); + groups.sort().forEach(function(value) { $('#connection-group').append($('').val(value).text(value)); }); + $('#connection-group').val(groups.indexOf(group) >= 0 ? group : ''); render_saved_connections(); } @@ -130,7 +138,10 @@ jQuery(function($){ for (i = 0; i < saved_connections.length; i++) { profile = saved_connections[i]; - option = $('').val(profile.id).text(profile.title); + var query = ($('#connection-search').val() || '').toLowerCase(), group = $('#connection-group').val(); + if ((group && profile.group !== group) || + [profile.name, profile.group, profile.notes, profile.title].join(' ').toLowerCase().indexOf(query) < 0) { continue; } + option = $('').val(profile.id).text((profile.group ? '[' + profile.group + '] ' : '') + (profile.name || profile.title)); saved_connections_select.append(option); } @@ -160,7 +171,9 @@ jQuery(function($){ } saved_connection_map[profile.id] = profile; + set_saved_connections(saved_connections); render_saved_connections(profile.id); + update_saved_key_hint(profile); } @@ -198,6 +211,7 @@ jQuery(function($){ var profile = selected_connection(); if (!profile) { apply_saved_password_hint(false); + update_saved_key_hint(null); return; } @@ -208,12 +222,31 @@ jQuery(function($){ $('#passphrase').val(''); $('#totp').val(''); $('#directory').val(profile.directory || ''); + $('#connection-name').val(profile.name || ''); + $('#connection-group-name').val(profile.group || ''); + $('#connection-notes').val(profile.notes || ''); term_type.val(profile.term || 'xterm-256color'); apply_saved_password_hint(profile.has_password); + $('#save-credentials').prop( + 'checked', !!(profile.has_password || profile.has_privatekey) + ); + $('#save-passphrase').prop('checked', !!profile.has_passphrase); + update_saved_key_hint(profile); status.text(''); } + function update_saved_key_hint(profile) { + $('#privatekey-hint').text( + profile && profile.has_privatekey ? + '已保存私钥,无需上传;上传新文件可替换' : '' + ); + $('#passphrase').attr('placeholder', + profile && profile.has_passphrase ? '已保存口令,留空即可使用' : '' + ); + } + + function apply_saved_password_hint(has_password) { // the server never sends saved passwords to the browser; an empty // field just tells it to use the one it already has on disk @@ -243,6 +276,8 @@ jQuery(function($){ return item.id !== profile.id; }) ); + apply_saved_password_hint(false); + update_saved_key_hint(null); }, error: function(resp) { log_status(resp.status + ': ' + resp.statusText); @@ -579,6 +614,7 @@ jQuery(function($){ function log_status(text, to_populate) { text = translate_status(text); + if (to_populate) { $('#workspace').removeClass('workspace-fullscreen'); } if (text) { console.log(text); } @@ -657,362 +693,58 @@ jQuery(function($){ function ajax_complete_callback(resp) { button.prop('disabled', false); - - if (resp.status !== 200) { - log_status(resp.status + ': ' + resp.statusText, true); - state = DISCONNECTED; - return; - } - + state = DISCONNECTED; + waiter.hide(); var msg = resp.responseJSON; - if (!msg.id) { - log_status(msg.status, true); - state = DISCONNECTED; + if (resp.status !== 200 || !msg || !msg.id) { + log_status(msg && msg.status || resp.status + ': ' + resp.statusText, true); return; } - - if (msg.profile) { - upsert_saved_connection(msg.profile); - } - - var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'), - join = (ws_url[ws_url.length-1] === '/' ? '' : '/'), - url = ws_url + join + 'ws?id=' + msg.id, - sock = new window.WebSocket(url), - encoding = 'utf-8', - decoder = window.TextDecoder ? new window.TextDecoder(encoding) : encoding, - terminal = document.getElementById('terminal'), - termOptions = { - cursorBlink: true, - theme: { - background: url_opts_data.bgcolor || '#0f1720', - foreground: url_opts_data.fontcolor || '#d9f1ee', - cursor: url_opts_data.cursor || url_opts_data.fontcolor || '#55c7ba' + if (msg.profile) { upsert_saved_connection(msg.profile); } + status.text(''); + wssh.workspace.create(msg, pending_connection || {}, $.extend({}, url_opts_data), { + toast: show_terminal_toast, translate: translate_status, + copy: copy_text_to_clipboard, clipboard: read_clipboard_text, + font: update_font_family, + reconnect: function(connection) { + $('#workspace').removeClass('workspace-fullscreen'); + $('#hostname').val(connection.hostname); + $('#port').val(connection.port); + $('#username').val(connection.username); + $('#directory').val(connection.directory || ''); + $('#connection-name').val(connection.name || ''); + $('#connection-group-name').val(connection.group || ''); + $('#connection-notes').val(connection.notes || ''); + $('#password, #privatekey, #passphrase, #totp').val(''); + var id, profile; + for (id in saved_connection_map) { + profile = saved_connection_map[id]; + if (profile.hostname === connection.hostname && + Number(profile.port) === Number(connection.port || 22) && + profile.username === connection.username) { + saved_connections_select.val(profile.id); + $('#connection-search').val(''); + $('#connection-group').val(''); + render_saved_connections(profile.id); + apply_saved_connection(); + if (!profile.requires_totp && (profile.has_password || + (profile.has_privatekey && (!profile.key_requires_passphrase || profile.has_passphrase)))) { + connect(); + } else { + form_container.show(); + log_status('连接已载入,可直接连接;加密私钥需补充口令,双因素认证需填写验证码。'); + } + return; } - }; - - if (url_opts_data.fontsize) { - var fontsize = window.parseInt(url_opts_data.fontsize); - if (fontsize && fontsize > 0) { - termOptions.fontSize = fontsize; - } - } - - clear_terminal(); - var term = new window.Terminal(termOptions); - current_terminal = term; - - term.fitAddon = new window.FitAddon.FitAddon(); - term.loadAddon(term.fitAddon); - - console.log(url); - if (!msg.encoding) { - console.log('无法检测服务器默认编码'); - msg.encoding = encoding; - } else { - console.log('服务器默认编码是 ' + msg.encoding); - } - - function term_write(text) { - if (term) { - term.write(text); - if (!term.resized) { - resize_terminal(term); - term.resized = true; } - } - } - - function set_encoding(new_encoding) { - // for console use - if (!new_encoding) { - console.log('请提供编码名称'); - return; - } - - if (!window.TextDecoder) { - decoder = new_encoding; - encoding = decoder; - console.log('已将编码设置为 ' + encoding); - } else { - try { - decoder = new window.TextDecoder(new_encoding); - encoding = decoder.encoding; - console.log('已将编码设置为 ' + encoding); - } catch (RangeError) { - console.log('未知编码 ' + new_encoding); - return false; - } - } - } - - wssh.set_encoding = set_encoding; - - if (url_opts_data.encoding) { - if (set_encoding(url_opts_data.encoding) === false) { - set_encoding(msg.encoding); - } - } else { - set_encoding(msg.encoding); - } - - - wssh.geometry = function() { - // for console use - var geometry = current_geometry(term); - console.log('当前终端尺寸:' + JSON.stringify(geometry)); - }; - - wssh.send = function(data) { - // for console use - if (!sock) { - console.log('WebSocket 已关闭'); - return; - } - - if (typeof data !== 'string') { - console.log('只允许发送字符串'); - return; - } - - try { - JSON.parse(data); - sock.send(data); - } catch (SyntaxError) { - data = data.trim() + '\r'; - sock.send(JSON.stringify({'data': data})); - } - }; - - wssh.reset_encoding = function() { - // for console use - if (encoding === msg.encoding) { - console.log('已经恢复为 ' + msg.encoding); - } else { - set_encoding(msg.encoding); - } - }; - - wssh.resize = function(cols, rows) { - // for console use - if (term === undefined) { - console.log('终端已经关闭'); - return; - } - - var valid_args = false; - - if (cols > 0 && rows > 0) { - var geometry = current_geometry(term); - if (cols <= geometry.cols && rows <= geometry.rows) { - valid_args = true; - } - } - - if (!valid_args) { - console.log('无法调整终端尺寸为:' + format_geometry(cols, rows)); - } else { - term.on_resize(cols, rows); - } - }; - - wssh.set_bgcolor = function(color) { - set_backgound_color(term, color); - }; - - wssh.set_fontcolor = function(color) { - set_font_color(term, color); - }; - - wssh.custom_font = function() { - update_font_family(term); - }; - - wssh.default_font = function() { - reset_font_family(term); - }; - - term.on_resize = function(cols, rows) { - if (cols !== this.cols || rows !== this.rows) { - console.log('正在调整终端尺寸为:' + format_geometry(cols, rows)); - this.resize(cols, rows); - sock.send(JSON.stringify({'resize': [cols, rows]})); - } - }; - - term.onData(function(data) { - // console.log(data); - sock.send(JSON.stringify({'data': data})); - }); - - function send_data(data) { - if (sock && sock.readyState === window.WebSocket.OPEN) { - sock.send(JSON.stringify({'data': data})); - } - } - - function copy_terminal_selection() { - if (!term || !term.hasSelection()) { - return false; - } - - var selection = term.getSelection(); - copy_text_to_clipboard(selection, function(copied) { - if (copied && term) { - term.clearSelection(); - } - if (term) { - term.focus(); - } - show_terminal_toast(copied ? '已复制' : '复制失败'); - }); - return true; - } - - function paste_into_terminal(show_unavailable) { - var started = read_clipboard_text( - function(text) { - if (text && term) { - // Let xterm normalize newlines and honor bracketed-paste mode. - term.paste(text); - term.focus(); - } - }, - function() { - show_terminal_toast('剪贴板权限被拒绝,请使用 Ctrl+V 粘贴'); - } - ); - if (!started && show_unavailable !== false) { - show_terminal_toast('浏览器不支持直接读取剪贴板,请使用 Ctrl+V'); - } - return started; - } - - term.attachCustomKeyEventHandler(function(e) { - if (e.type !== 'keydown') { - return true; - } - - var key = e.key ? e.key.toLowerCase() : ''; - - // Ctrl+Shift+C / Cmd+C: copy the active terminal selection. - if (((e.ctrlKey && e.shiftKey) || e.metaKey) && key === 'c' && - term.hasSelection()) { - e.preventDefault(); - copy_terminal_selection(); - return false; - } - - // Do not let xterm translate Ctrl/Cmd+V into a control character. By - // returning false without preventDefault, the browser can dispatch its - // trusted paste event to xterm's hidden textarea. This works without the - // async Clipboard API and therefore also works on plain HTTP deployments. - if ((e.ctrlKey || e.metaKey) && !e.altKey && key === 'v') { - return false; - } - // Ctrl+C with an active selection copies instead of sending SIGINT - if (e.ctrlKey && !e.shiftKey && !e.altKey && key === 'c' && - term.hasSelection()) { - e.preventDefault(); - copy_terminal_selection(); - return false; - } - return true; - }); - - // Right click: copy the selection if there is one, otherwise paste. When - // programmatic clipboard reads are unavailable, keep the native context - // menu so the browser's Paste command remains usable. - $('#terminal').off('contextmenu.wssh').on('contextmenu.wssh', function(e) { - if (!term) { - return; - } - if (term.hasSelection()) { - e.preventDefault(); - copy_terminal_selection(); - } else if (paste_into_terminal(false)) { - e.preventDefault(); - } else { - show_terminal_toast('请从右键菜单选择粘贴,或使用 Ctrl+V'); - } - }); - - // Keep terminal focus (and selection) when pressing quickbar buttons - quickbar.off('mousedown.wssh').on('mousedown.wssh', '.quickbar-btn', - function(e) { - e.preventDefault(); - } - ); - - quickbar.off('click.wssh').on('click.wssh', '.quickbar-btn', function(e) { - var btn = $(this), - action = btn.attr('data-action'), - key = btn.attr('data-key'), - named_keys = {'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'}; - - e.preventDefault(); - - if (action === 'copy') { - if (!copy_terminal_selection()) { - show_terminal_toast('请先选中要复制的内容'); - } - } else if (action === 'paste') { - paste_into_terminal(); - } else if (key) { - send_data(named_keys[key] || key); - } - - if (term) { - term.focus(); - } - }); - - sock.onopen = function() { - term.open(terminal); - quickbar.show(); - $('body').addClass('quickbar-open'); - toggle_fullscreen(term); - update_font_family(term); - term.focus(); - state = CONNECTED; - title_element.text = url_opts_data.title || default_title; - if (url_opts_data.command) { - setTimeout(function () { - sock.send(JSON.stringify({'data': url_opts_data.command+'\r'})); - }, 500); - } - }; - - sock.onmessage = function(msg) { - read_file_as_text(msg.data, term_write, decoder); - }; - - sock.onerror = function(e) { - console.error(e); - }; - - sock.onclose = function(e) { - var reason = write_disconnect_message(term, e.reason); - - $('#terminal').removeClass('terminal-fullscreen'); - quickbar.hide(); - $('body').removeClass('quickbar-open'); - sock = undefined; - reset_wssh(); - log_status(reason, true); - term = undefined; - state = DISCONNECTED; - default_title = 'WebSSH'; - title_element.text = default_title; - }; - - // namespaced so reconnecting replaces the handler instead of stacking - // another one on top of it - $(window).off('resize.wssh').on('resize.wssh', function(){ - if (term) { - resize_terminal(term); + form_container.show(); + log_status('请补充密码、私钥或动态验证码后连接。'); } }); + delete url_opts_data.command; + $('#password, #privatekey, #passphrase, #totp').val(''); + validated_form_data = undefined; + pending_connection = undefined; } @@ -1161,6 +893,7 @@ jQuery(function($){ if (pk && pk.size && !debug) { read_file_as_text(pk, function(text) { if (text === undefined) { + state = DISCONNECTED; log_status('私钥文件无效:' + pk.name); } else { ajax_post(); @@ -1210,7 +943,7 @@ jQuery(function($){ // for console use var result, opts; - if (state !== DISCONNECTED) { + if (state === CONNECTING) { console.log(messages[state]); return; } @@ -1236,7 +969,12 @@ jQuery(function($){ result = connect_with_options(opts); } - if (result) { + if (result && result.valid) { + pending_connection = {}; + ['hostname', 'port', 'username', 'directory', 'term', 'name', 'group', 'notes'].forEach(function(key) { + pending_connection[key] = result.data.get(key) || ''; + }); + pending_connection.requires_totp = !!result.data.get('totp'); state = CONNECTING; default_title = result.title; if (hostname) { @@ -1258,9 +996,24 @@ jQuery(function($){ update_password_visibility(); saved_connections_select.val(''); apply_saved_password_hint(false); + update_saved_key_hint(null); }, 0); }); + $('#connection-search').on('input', function() { render_saved_connections(saved_connections_select.val()); }); + $('#connection-group').on('change', function() { render_saved_connections(); }); + wssh.refresh_connections = function(connections) { set_saved_connections(connections); }; + $('#edit-connection').on('click', function() { + var profile = selected_connection(); + if (!profile) { log_status('请先选择连接。'); return; } + $.ajax({url: 'connections/' + profile.id + '/metadata', type: 'POST', + contentType: 'application/json', headers: {'X-XSRFToken': get_xsrf_token()}, + data: JSON.stringify({name: $('#connection-name').val(), group: $('#connection-group-name').val(), notes: $('#connection-notes').val()}), + success: function(data) { upsert_saved_connection(data.profile); set_saved_connections(saved_connections); saved_connections_select.val(data.profile.id); show_terminal_toast('备注已保存'); }, + error: function() { log_status('保存备注失败。'); } + }); + }); + load_connection_button.click(function() { apply_saved_connection(); }); @@ -1269,6 +1022,18 @@ jQuery(function($){ apply_saved_connection(); }); + $('#hostname, #port, #username').on('input', function() { + $('#password').attr('placeholder', ''); + update_saved_key_hint(null); + }); + + $('#privatekey').on('change', function() { + $('#passphrase').val('').attr('placeholder', ''); + $('#privatekey-hint').text( + this.files.length ? '成功连接并勾选保存凭据后替换已保存私钥' : '' + ); + }); + delete_connection_button.click(function() { delete_saved_connection(); }); diff --git a/webssh/static/js/workspace.js b/webssh/static/js/workspace.js new file mode 100644 index 0000000..59e7ec8 --- /dev/null +++ b/webssh/static/js/workspace.js @@ -0,0 +1,435 @@ +/* Independent terminals and browser-local preferences. */ +(function($) { + 'use strict'; + var sessions = [], active, helpers, searchTimer, + settings = readLocal('webssh.terminal', {}), + commands = readLocal('webssh.commands', []), fileGeneration = 0; + var themes = { + dark: {background: '#0f1720', foreground: '#d9f1ee', cursor: '#55c7ba'}, + light: {background: '#fafaf7', foreground: '#17252a', cursor: '#21776d'}, + green: {background: '#122b24', foreground: '#d3ebd3', cursor: '#91c99d'} + }; + + function readLocal(key, fallback) { + try { return JSON.parse(window.localStorage.getItem(key)) || fallback; } + catch (e) { return fallback; } + } + function saveLocal(key, value) { + try { window.localStorage.setItem(key, JSON.stringify(value)); return true; } + catch (e) { notify('浏览器未允许保存设置。'); return false; } + } + function notify(text) { + if (helpers) { helpers.toast(text); } + else { $('#status').text(text); } + } + function xsrf() { return $('input[name="_xsrf"]').val() || ''; } + function api(url, data) { + return $.ajax({url: url, type: 'POST', contentType: 'application/json', + headers: {'X-XSRFToken': xsrf()}, data: JSON.stringify(data)}); + } + function askPassword(title) { + return new Promise(function(resolve) { + var previous = document.activeElement; + $('#backup-password-title').text(title); + $('#backup-password-dialog').prop('hidden', false); + $('#backup-password').val('').focus(); + function finish(value) { + $('#backup-password').val(''); + $('#backup-password-dialog').prop('hidden', true); + $('#backup-password-form, #cancel-backup-password').off('.password'); + $(document).off('keydown.password'); + if (previous) { previous.focus(); } + resolve(value); + } + $('#backup-password-form').off('.password').on('submit.password', function(event) { + event.preventDefault(); finish($('#backup-password').val()); + }); + $('#cancel-backup-password').off('.password').on('click.password', function() { finish(null); }); + $(document).on('keydown.password', function(event) { + if (event.key === 'Escape') { finish(null); } + if (event.key === 'Tab') { + var elements = $('#backup-password-form').find('input, button').toArray(); + var index = elements.indexOf(document.activeElement); + event.preventDefault(); elements[(index + (event.shiftKey ? -1 : 1) + elements.length) % elements.length].focus(); + } + }); + }); + } + function downloadBlob(blob, name) { + var url = URL.createObjectURL(blob), link = document.createElement('a'); + link.href = url; link.download = name; + document.body.appendChild(link); link.click(); link.remove(); + window.setTimeout(function() { URL.revokeObjectURL(url); }, 10000); + } + function send(session, text) { + if (session && session.socket.readyState === window.WebSocket.OPEN) { + session.socket.send(JSON.stringify({data: text})); + } else { notify('会话已断开,请重新连接。'); } + } + function fit(session) { + if (!session || !session.opened || session !== active) { return; } + try { session.fit.fit(); } catch (e) { /* Hidden containers cannot fit. */ } + } + function renderTabs() { + $('#session-tabs').empty(); + sessions.forEach(function(session) { + var label = session.connection.name || session.connection.username + '@' + session.connection.hostname; + var tab = $('').text(label + ' · ' + session.status) + .attr('aria-selected', session === active ? 'true' : 'false') + .on('click', function() { activate(session); }); + $('#session-tabs').append(tab); + }); + $('#session-tools').prop('hidden', sessions.length === 0); + } + function activate(session) { + active = session; + sessions.forEach(function(item) { item.container.hidden = item !== session; }); + renderTabs(); + $('#files-panel').prop('hidden', true); fileGeneration++; + $('#file-list').empty(); $('#file-status').text(''); + $('#file-path').val(session && session.path || '.'); + $('#search-result').text(''); + $('#status').text(session && session.reason || ''); + $('#quickbar').toggle(!!session); + $('body').toggleClass('quickbar-open', !!session); + document.title = session ? (session.connection.name || session.connection.hostname) + ' · WebSSH' : 'WebSSH'; + if (session) { window.requestAnimationFrame(function() { fit(session); session.term.focus(); }); } + } + function closeSession(session) { + if (!session) { return; } + session.removed = true; + session.socket.close(); session.term.dispose(); session.container.remove(); + sessions = sessions.filter(function(item) { return item !== session; }); + if (session === active) { activate(sessions[sessions.length - 1]); } + else { renderTabs(); } + if (!sessions.length) { + $('#workspace').removeClass('workspace-fullscreen'); + $('.form-container').show(); + } + } + function terminalOptions(opts) { + return {fontSize: Math.max(10, Math.min(32, Number(settings.fontSize || opts.fontsize) || 14)), + cursorBlink: settings.cursorBlink !== false, + cursorStyle: ['block', 'bar', 'underline'].indexOf(settings.cursorStyle) >= 0 ? settings.cursorStyle : 'block', + scrollback: 5000, + theme: themes[settings.theme] || {background: opts.bgcolor || '#0f1720', foreground: opts.fontcolor || '#d9f1ee', cursor: opts.cursor || '#55c7ba'}}; + } + function copySelection(session) { + if (!session || !session.term.hasSelection()) { return false; } + helpers.copy(session.term.getSelection(), function(ok) { + notify(ok ? '已复制' : '复制失败'); + if (!session.removed) { session.term.focus(); } + }); + return true; + } + function pasteClipboard(session) { + return helpers.clipboard(function(text) { + if (!session.removed && session.socket.readyState === window.WebSocket.OPEN) { + session.term.paste(text); session.term.focus(); + } + }, function() { notify('请使用 Ctrl+V 粘贴。'); }); + } + function create(msg, connection, opts, callbacks) { + helpers = callbacks; + var container = document.createElement('div'); + container.className = 'session-terminal'; + document.getElementById('terminal').appendChild(container); + var base = window.location.href.split(/[?#]/)[0].replace(/^http/, 'ws'); + var socket = new window.WebSocket(base.replace(/\/$/, '') + '/ws?id=' + encodeURIComponent(msg.id)); + socket.binaryType = 'arraybuffer'; + var term = new window.Terminal(terminalOptions(opts)), fitAddon = new window.FitAddon.FitAddon(); + term.loadAddon(fitAddon); + var session = {id: msg.id, connection: connection, socket: socket, term: term, + fit: fitAddon, container: container, status: '连接中', opened: false, + path: connection.directory || '.', removed: false, matches: [], matchIndex: -1}; + session.defaultEncoding = msg.encoding || 'utf-8'; + session.defaultFont = term.getOption('fontFamily'); + sessions.push(session); activate(session); + var decoder; + function encoding(name) { + if (!window.TextDecoder) { decoder = null; return false; } + try { decoder = new window.TextDecoder(name || 'utf-8'); return true; } + catch (e) { decoder = new window.TextDecoder('utf-8'); return false; } + } + encoding(opts.encoding || msg.encoding); + session.encoding = encoding; + term.onData(function(data) { send(session, data); }); + term.onResize(function(size) { + if (socket.readyState === window.WebSocket.OPEN) { + socket.send(JSON.stringify({resize: [size.cols, size.rows]})); + } + }); + term.attachCustomKeyEventHandler(function(event) { + if (event.type !== 'keydown') { return true; } + var key = (event.key || '').toLowerCase(); + if ((event.ctrlKey || event.metaKey) && key === 'f') { + event.preventDefault(); activate(session); + $('#search-panel').prop('hidden', false); $('#terminal-search').focus(); return false; + } + if ((event.ctrlKey || event.metaKey) && key === 'c' && term.hasSelection()) { + event.preventDefault(); copySelection(session); return false; + } + if ((event.ctrlKey || event.metaKey) && key === 'v') { return false; } + return true; + }); + $(container).on('contextmenu', function(event) { + if (copySelection(session) || pasteClipboard(session)) { event.preventDefault(); } + }); + socket.onopen = function() { + if (session.removed) { socket.close(); return; } + term.open(container); session.opened = true; session.status = '已连接'; + helpers.font(term); renderTabs(); fit(session); + if (session === active) { $('.form-container').hide(); term.focus(); } + if (opts.command) { send(session, opts.command + '\r'); } + }; + socket.onmessage = function(event) { + if (!session.removed) { + term.write(typeof event.data === 'string' ? event.data : + (decoder ? decoder.decode(new Uint8Array(event.data), {stream: true}) : new Uint8Array(event.data))); + } + }; + socket.onerror = function() { session.status = '连接异常'; renderTabs(); }; + socket.onclose = function(event) { + if (session.removed) { return; } + session.status = '已断开'; renderTabs(); + var reason = helpers.translate(event.reason) || '连接已断开,点击“重新连接”可建立新会话。'; + session.reason = reason; + session.term.write('\r\n\r\n' + reason.replace(/[\x00-\x1f\x7f]/g, ' ') + '\r\n'); + session.term.setOption('disableStdin', true); + if (session === active) { $('#status').text(reason); } + }; + return session; + } + + // Map UTF-16 positions to terminal cells, including wide characters and wraps. + function findMatches(session, query) { + var buffer = session.term.buffer.active, text = '', positions = []; + for (var y = 0; y < buffer.length; y++) { + var line = buffer.getLine(y), next = buffer.getLine(y + 1); + if (!line) { continue; } + var limit = next && next.isWrapped ? session.term.cols : line.translateToString(true).length; + var lineText = ''; + for (var x = 0; x < session.term.cols; x++) { + var cell = line.getCell(x); + if (!cell || cell.getWidth() === 0) { continue; } + var chars = cell.getChars() || ' '; + if (!(next && next.isWrapped) && lineText.length >= limit) { break; } + for (var unit = 0; unit < chars.length; unit++) { + positions.push({x: x, y: y, width: cell.getWidth()}); + } + text += chars; lineText += chars; + } + if (!next || !next.isWrapped) { text += '\n'; positions.push(null); } + } + var matches = [], at = 0; + while (query && (at = text.indexOf(query, at)) >= 0) { + var start = positions[at], end = positions[at + query.length - 1]; + if (start && end) { matches.push({start: start, end: end}); } + at += Math.max(1, query.length); + } + return matches; + } + function search(direction) { + if (!active || !active.opened) { return; } + var query = $('#terminal-search').val(); + active.matches = findMatches(active, query); + if (active.searchQuery !== query) { active.matchIndex = -1; active.searchQuery = query; } + var count = active.matches.length; + if (!count) { active.term.clearSelection(); $('#search-result').text(query ? '未找到' : ''); return; } + active.matchIndex = (active.matchIndex + direction + count) % count; + var match = active.matches[active.matchIndex]; + active.term.select(match.start.x, match.start.y, + (match.end.y - match.start.y) * active.term.cols + match.end.x + match.end.width - match.start.x); + active.term.scrollToLine(match.start.y); + $('#search-result').text((active.matchIndex + 1) + ' / ' + count); + } + function renderCommands() { + $('#command-list').empty().append($('').val('').text('选择常用命令')); + commands.forEach(function(command, index) { + $('#command-list').append($('').val(index).text(command.name)); + }); + } + function selectedCommand() { + var index = $('#command-list').val(); + return index === '' ? null : commands[Number(index)]; + } + function listFiles(path) { + var session = active; + if (!session || session.socket.readyState !== window.WebSocket.OPEN) { notify('请先连接服务器。'); return; } + var generation = ++fileGeneration; + $('#file-status').text('正在读取目录…'); $('#file-list').empty(); + $.ajax({url: 'files/' + encodeURIComponent(session.id), data: {path: path}}) + .done(function(result) { + if (active !== session || generation !== fileGeneration) { return; } + session.path = result.path; $('#file-path').val(result.path); $('#file-status').text(''); + result.entries.forEach(function(entry) { + var button = $('').text( + (entry.directory ? '📁 ' : '📄 ') + entry.name + (entry.directory ? '' : ' · ' + entry.size + ' B')); + button.on('click', function() { + var path = result.path.replace(/\/$/, '') + '/' + entry.name; + if (entry.directory) { listFiles(path); } + else { downloadFile(session, path, entry.name); } + }); + $('#file-list').append(button); + }); + }).fail(function() { + if (active === session && generation === fileGeneration) { + $('#file-status').text('目录读取失败,请检查 SFTP 支持、权限或连接状态。'); + } + }); + } + function downloadFile(session, path, name) { + $('#file-status').text('正在下载…'); + var url = 'files/' + encodeURIComponent(session.id) + '?download=1&path=' + encodeURIComponent(path); + fetch(url, {credentials: 'same-origin'}).then(function(response) { + if (!response.ok) { throw new Error(); } + return response.blob(); + }).then(function(blob) { + downloadBlob(blob, name); + if (active === session) { $('#file-status').text('下载完成'); } + }).catch(function() { if (active === session) { $('#file-status').text('下载失败,文件上限为 16 MiB。'); } }); + } + + window.wssh.workspace = {create: create}; + window.wssh.send = function(text) { + if (typeof text !== 'string' || !active || active.socket.readyState !== window.WebSocket.OPEN) { return; } + try { JSON.parse(text); active.socket.send(text); } + catch (e) { send(active, text.trim() + '\r'); } + }; + window.wssh.set_encoding = function(name) { if (active) { return active.encoding(name); } }; + window.wssh.reset_encoding = function() { if (active) { active.encoding(active.defaultEncoding); } }; + window.wssh.custom_font = function() { if (active) { helpers.font(active.term); } }; + window.wssh.default_font = function() { + if (active) { active.term.setOption('fontFamily', active.defaultFont); active.term.font_family_updated = false; fit(active); } + }; + window.wssh.geometry = function() { return active && {cols: active.term.cols, rows: active.term.rows}; }; + window.wssh.resize = function(cols, rows) { if (active) { active.term.resize(cols, rows); } }; + window.wssh.set_bgcolor = function(color) { if (active) { active.term.setOption('theme', {background: color}); } }; + window.wssh.set_fontcolor = function(color) { if (active) { active.term.setOption('theme', {foreground: color}); } }; + + $(function() { + if (!Array.isArray(commands)) { commands = []; } + commands = commands.filter(function(item) { return item && typeof item.name === 'string' && typeof item.text === 'string'; }); + renderCommands(); + $('#terminal-fontsize').val(settings.fontSize || 14); + $('#terminal-theme').val(settings.theme || 'dark'); + $('#terminal-cursor').val(settings.cursorStyle || 'block'); + $('#terminal-blink').prop('checked', settings.cursorBlink !== false); + $('#new-session').on('click', function() { + $('#workspace').removeClass('workspace-fullscreen'); + $('.form-container').show(); fit(active); + document.getElementById('connect').scrollIntoView({behavior: 'smooth'}); + }); + $('#close-session').on('click', function() { closeSession(active); }); + $('#reconnect-session').on('click', function() { + if (!active) { return; } + if (active.socket.readyState === window.WebSocket.OPEN || active.socket.readyState === window.WebSocket.CONNECTING) { + notify('当前会话仍在连接,请先关闭或新建连接。'); return; + } + helpers.reconnect(active.connection); + }); + ['search', 'settings', 'commands', 'files'].forEach(function(name) { + $('#toggle-' + name).on('click', function() { + var panel = $('#' + name + '-panel'); panel.prop('hidden', !panel.prop('hidden')); + if (name === 'files' && !panel.prop('hidden')) { listFiles(active && active.path || '.'); } + if (name === 'search' && !panel.prop('hidden')) { $('#terminal-search').focus(); } + fit(active); + }); + }); + $('#toggle-fullscreen').on('click', function() { $('#workspace').toggleClass('workspace-fullscreen'); fit(active); }); + $(document).on('keydown.workspace', function(event) { + if (event.key === 'Escape' && $('#workspace').hasClass('workspace-fullscreen')) { + $('#workspace').removeClass('workspace-fullscreen'); fit(active); + } + }); + $(window).on('resize.workspace', function() { fit(active); }); + window.addEventListener('beforeunload', function(event) { + if (sessions.some(function(session) { return session.socket.readyState === window.WebSocket.OPEN; })) { + event.preventDefault(); event.returnValue = ''; + } + }); + $('#terminal-search').on('input', function() { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(function() { search(1); }, 150); }); + $('#terminal-search').on('keydown', function(event) { if (event.key === 'Enter') { event.preventDefault(); search(event.shiftKey ? -1 : 1); } }); + $('#search-prev').on('click', function() { search(-1); }); + $('#search-next').on('click', function() { search(1); }); + $('#save-terminal-settings').on('click', function() { + settings = {fontSize: Math.max(10, Math.min(32, Number($('#terminal-fontsize').val()) || 14)), + theme: $('#terminal-theme').val(), cursorStyle: $('#terminal-cursor').val(), cursorBlink: $('#terminal-blink').prop('checked')}; + sessions.forEach(function(session) { + var opts = terminalOptions({}); + Object.keys(opts).forEach(function(key) { session.term.setOption(key, opts[key]); }); + }); + if (saveLocal('webssh.terminal', settings)) { notify('设置已保存'); } + fit(active); + }); + $('#command-list').on('change', function() { var command = selectedCommand(); $('#command-name').val(command ? command.name : ''); $('#command-text').val(command ? command.text : ''); }); + $('#save-command').on('click', function() { + var name = $('#command-name').val().trim(), text = $('#command-text').val().trim(), index = $('#command-list').val(); + if (!name || !text || name.length > 100 || text.length > 4096 || /[\x00-\x1f\x7f]/.test(text)) { + notify('请输入名称和单行命令,不包含换行或控制字符。'); return; + } + if (index === '') { commands.push({name: name, text: text}); } else { commands[Number(index)] = {name: name, text: text}; } + saveLocal('webssh.commands', commands); renderCommands(); + }); + $('#delete-command').on('click', function() { var index = $('#command-list').val(); if (index !== '') { commands.splice(Number(index), 1); saveLocal('webssh.commands', commands); renderCommands(); $('#command-name, #command-text').val(''); } }); + $('#insert-command').on('click', function() { + var command = selectedCommand(); + if (!active || !command || active.socket.readyState !== window.WebSocket.OPEN) { notify('请连接服务器并选择命令。'); return; } + if (/[\x00-\x1f\x7f]/.test(command.text)) { notify('仅支持单行命令。'); return; } + active.term.paste(command.text); active.term.focus(); + }); + $('#quickbar').on('mousedown.workspace', 'button', function(event) { event.preventDefault(); }); + $('#quickbar').on('click.workspace', 'button', function() { + if (!active) { return; } + var action = $(this).attr('data-action'), key = $(this).attr('data-key'); + if (action === 'copy') { if (!copySelection(active)) { notify('请先选中内容。'); } } + else if (action === 'paste') { if (!pasteClipboard(active)) { notify('请使用 Ctrl+V。'); } } + else { send(active, {'ctrl-c': '\x03', tab: '\t', esc: '\x1b'}[key] || key); } + active.term.focus(); + }); + $('#file-refresh').on('click', function() { listFiles($('#file-path').val()); }); + $('#file-path').on('keydown', function(event) { if (event.key === 'Enter') { event.preventDefault(); listFiles(this.value); } }); + $('#file-parent').on('click', function() { listFiles((active && active.path || '.') + '/..'); }); + $('#file-upload').on('change', function() { + var file = this.files[0], session = active; + this.value = ''; + if (!file || !session || session.socket.readyState !== window.WebSocket.OPEN) { notify('请先连接服务器。'); return; } + if (file.size > 16 * 1024 * 1024) { notify('单个文件最大为 16 MiB。'); return; } + var data = new FormData(); data.append('file', file); data.append('path', session.path || '.'); + $('#file-status').text('正在上传…'); + $.ajax({url: 'files/' + encodeURIComponent(session.id), type: 'POST', data: data, + processData: false, contentType: false, headers: {'X-XSRFToken': xsrf()}}) + .done(function() { if (session === active) { listFiles(session.path); notify('上传完成'); } }) + .fail(function() { if (session === active) { $('#file-status').text('上传失败:同名文件已存在、权限不足或连接中断。'); } }); + }); + function exportConfig(credentials) { + var request = credentials ? askPassword('独立备份口令(至少 10 个字符,迁移时需要):') : Promise.resolve(''); + request.then(function(password) { + if (password === null) { return; } + api('connections/export', {credentials: credentials, password: password}) + .done(function(document) { downloadBlob(new Blob([JSON.stringify(document, null, 2)], {type: 'application/json'}), credentials ? 'webssh-backup.json' : 'webssh-connections.json'); }) + .fail(function() { notify('导出失败,请确认备份口令至少 10 个字符,且已保存凭据可解密。'); }); + }); + } + $('#export-config').on('click', function() { exportConfig(false); }); + $('#backup-credentials').on('click', function() { exportConfig(true); }); + $('#import-config').on('change', function() { + var file = this.files[0]; this.value = ''; + if (!file) { return; } + if (file.size > 16 * 1024 * 1024) { notify('配置文件最大为 16 MiB。'); return; } + var reader = new FileReader(); + reader.onload = function() { + var document; + try { document = JSON.parse(reader.result); } catch (e) { notify('配置文件不是有效 JSON。'); return; } + var request = document && document.format === 'webssh-encrypted' ? askPassword('请输入备份口令:') : Promise.resolve(''); + request.then(function(password) { + if (password === null) { return; } + api('connections/import', {document: document, password: password}) + .done(function(result) { window.wssh.refresh_connections(result.connections); notify('已导入 ' + result.imported + ' 个连接'); }) + .fail(function() { notify('导入失败,请检查文件格式、地址或备份口令。'); }); + }); + }; + reader.onerror = function() { notify('无法读取配置文件。'); }; reader.readAsText(file); + }); + }); +}(jQuery)); diff --git a/webssh/storage.py b/webssh/storage.py index 0ce597a..b8593d7 100755 --- a/webssh/storage.py +++ b/webssh/storage.py @@ -5,12 +5,18 @@ import threading import time from webssh.crypto import SecretBox, is_encrypted +from webssh.utils import ( + is_valid_directory, is_valid_hostname, is_valid_ip_address +) DEFAULT_PORT = 22 # keys that must never be handed out to a browser -PRIVATE_FIELDS = ('password', 'password_enc') +PRIVATE_FIELDS = ( + 'password', 'password_enc', 'privatekey', 'privatekey_enc', + 'passphrase', 'passphrase_enc' +) class ConnectionStore(object): @@ -51,6 +57,141 @@ class ConnectionStore(object): def make_id(self, owner, hostname, port, username): return self._make_id(owner, hostname, port, username) + def get_privatekey(self, owner, profile_id): + """Return key material only to the server, scoped to its owner.""" + with self.lock: + for profile in self._read().get('connections', []): + if (profile.get('owner') == owner and + profile.get('id') == profile_id): + if not self.box: + return '', '' + return ( + self.box.decrypt(profile.get('privatekey_enc')), + self.box.decrypt(profile.get('passphrase_enc')) + ) + return '', '' + + def update_metadata(self, owner, profile_id, metadata): + values = self._metadata(metadata) + with self.lock: + data = self._read() + for profile in data['connections']: + if (profile.get('owner') == owner and + profile.get('id') == profile_id): + profile.update(values) + profile['updated_at'] = int(time.time()) + self._write(data) + return self._public(profile) + return None + + def export_connections(self, owner, credentials=False): + with self.lock: + profiles = [] + for profile in self._read()['connections']: + if profile.get('owner') != owner: + continue + item = {key: profile.get(key, '') for key in ( + 'hostname', 'port', 'username', 'term', 'directory', + 'auth_type', 'name', 'group', 'notes' + )} + item['requires_totp'] = bool(profile.get('requires_totp')) + item['key_requires_passphrase'] = bool( + profile.get('key_requires_passphrase') + ) + if credentials: + if not self.box: + raise ValueError('Encryption is unavailable.') + for field in ('password', 'privatekey', 'passphrase'): + token = profile.get(field + '_enc') + value = self.box.decrypt(token) if token else '' + if token and not value: + raise ValueError('Stored credentials cannot be decrypted.') + if field == 'password' and not token: + value = profile.get('password') or '' + item[field] = value + profiles.append(item) + return profiles + + def import_connections(self, owner, connections, credentials=False): + if not isinstance(connections, list) or len(connections) > 500: + raise ValueError('最多导入 500 个连接。') + normalized = [] + for item in connections: + if not isinstance(item, dict): + raise ValueError('连接配置格式无效。') + connection = {key: item.get(key) for key in ( + 'hostname', 'port', 'username', 'term', 'directory', + 'auth_type', 'name', 'group', 'notes' + )} + connection['requires_totp'] = item.get('requires_totp') is True + if credentials: + for field in ('password', 'privatekey', 'passphrase'): + value = item.get(field, '') + if not isinstance(value, str) or len(value) > 16384: + raise ValueError('凭据格式无效。') + connection[field] = value + connection['save_passphrase'] = True + hostname = connection.get('hostname') + username = connection.get('username') + directory = connection.get('directory') or '' + port = connection.get('port') + if port is None or port == '': + port = 22 + connection['port'] = port + if (not isinstance(hostname, str) or + not (is_valid_hostname(hostname) or + is_valid_ip_address(hostname)) or + not isinstance(username, str) or + not username.strip() or len(username) > 256 or + not isinstance(port, int) or isinstance(port, bool) or + not 1 <= port <= 65535 or + not isinstance(directory, str) or + (directory and not is_valid_directory(directory)) or + not isinstance(connection.get('term') or '', str) or + len(connection.get('term') or '') > 100 or + connection.get('auth_type') not in + (None, '', 'password', 'privatekey')): + raise ValueError('连接地址、端口或目录无效。') + normalized.append(self._normalize(owner, connection)) + normalized[-1]['key_requires_passphrase'] = ( + item.get('key_requires_passphrase') is True + ) + with self.lock: + data = self._read() + profiles = data['connections'] + now = int(time.time()) + for profile in normalized: + existing = next((p for p in profiles + if p['id'] == profile['id']), None) + profile['updated_at'] = now + if existing: + if not credentials: + for field in PRIVATE_FIELDS: + if field in existing: + profile[field] = existing[field] + profile['key_requires_passphrase'] = bool( + existing.get('key_requires_passphrase') + ) + existing.update(profile) + if credentials: + for field in PRIVATE_FIELDS: + if field not in profile: + existing.pop(field, None) + else: + profile['created_at'] = now + profiles.append(profile) + self._write(data) + return len(normalized) + + def _metadata(self, connection): + result = {} + for field, limit in (('name', 100), ('group', 100), ('notes', 1000)): + value = connection.get(field) or '' + if not isinstance(value, str) or len(value) > limit: + raise ValueError('连接备注或分组过长。') + result[field] = value.strip() + return result + def upsert(self, owner, connection): profile = self._normalize(owner, connection) @@ -121,6 +262,8 @@ class ConnectionStore(object): public['has_password'] = bool( profile.get('password_enc') or profile.get('password') ) + public['has_privatekey'] = bool(profile.get('privatekey_enc')) + public['has_passphrase'] = bool(profile.get('passphrase_enc')) return public def _normalize(self, owner, connection): @@ -148,13 +291,28 @@ class ConnectionStore(object): 'directory': directory, 'auth_type': auth_type } - if password: + profile['requires_totp'] = bool(connection.get('requires_totp')) + profile.update(self._metadata(connection)) + save_credentials = connection.get('save_credentials', True) + if password and save_credentials: if self.box: profile['password_enc'] = self.box.encrypt(password) else: # no server secret to encrypt with; keep the legacy layout so # encrypt_plaintext_passwords() can upgrade it later profile['password'] = password + privatekey = connection.get('privatekey') or '' + if privatekey and save_credentials: + if not self.box: + raise ValueError('A server secret is required to save keys.') + profile['privatekey_enc'] = self.box.encrypt(privatekey) + profile['key_requires_passphrase'] = bool( + connection.get('passphrase') + ) + if connection.get('save_passphrase'): + profile['passphrase_enc'] = self.box.encrypt( + connection.get('passphrase') or '' + ) return profile def _make_id(self, owner, hostname, port, username): diff --git a/webssh/templates/index.html b/webssh/templates/index.html index 7a22ee0..82beecd 100755 --- a/webssh/templates/index.html +++ b/webssh/templates/index.html @@ -47,6 +47,11 @@ + + + 全部分组 + 已保存连接 未选择保存的连接 @@ -60,11 +65,28 @@ id="delete-connection"> 删除 + 保存备注 + 导出配置 + 加密备份 + 导入配置 / 备份 + + + 连接名称 + + + + 分组 + + + + 备注 + + 主机地址 私钥文件 + 私钥口令 @@ -120,6 +143,22 @@ 保存连接信息 + + + + 保存密码 / 私钥(加密) + + 取消后,成功连接并保存时清除原有凭据 + + + + + 同时保存私钥口令(加密) + + 不保存时,每次连接需输入口令;动态验证码不保存 + @@ -132,12 +171,68 @@ - + + + + + 新建连接 + 重新连接 + 关闭标签 + 搜索输出 + 终端设置 + 常用命令 + 文件管理 + 全屏 + + + + 上一处 + 下一处 + + + + 字号 + 主题 深色浅色护眼绿 + 光标 方块竖线下划线 + 光标闪烁 + 应用并记住 + + + + + + 保存 + 删除 + 填入终端 + 填入后由你按回车执行;收藏保存在当前浏览器中,请勿包含密码。 + + + + 打开 / 刷新 + 上一级 + 上传文件(最大 16 MiB,不覆盖同名文件) + + + 连接中断时上传可能留下未完成文件,请在远端确认。 + + + + + 备份口令 + + + 继续 + 取消 + + + 复制 @@ -162,5 +257,6 @@ +