feat: add saved SSH keys and terminal workspace tools
This commit is contained in:
+41
-4
@@ -66,10 +66,47 @@ You can also provide credentials with environment variables:
|
|||||||
|
|
||||||
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
|
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
|
||||||
|
|
||||||
Saved connections are stored under the data directory too. WebSSH saves
|
Saved connections are stored under the data directory too. Connection
|
||||||
hostname, port, username, ssh password and terminal type for quick
|
details include hostname, port, username, terminal type, initial directory,
|
||||||
reconnects. It does not persist private keys, key passphrases or TOTP
|
name, group and notes. Search or filter saved connections by group, and use
|
||||||
codes.
|
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
|
Use ``--auth=false`` only for a trusted private deployment where another
|
||||||
layer already protects access to WebSSH.
|
layer already protects access to WebSSH.
|
||||||
|
|||||||
+41
-3
@@ -852,6 +852,44 @@ class TestAppWithUnknownEncoding(OtherTestBase):
|
|||||||
|
|
||||||
class TestAppWithSavedConnections(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):
|
def async_get(self, path):
|
||||||
return self.get_http_client().fetch(
|
return self.get_http_client().fetch(
|
||||||
self.get_url(path), headers=self.headers
|
self.get_url(path), headers=self.headers
|
||||||
@@ -859,7 +897,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
|||||||
|
|
||||||
@tornado.testing.gen_test
|
@tornado.testing.gen_test
|
||||||
def test_saved_password_never_reaches_the_browser(self):
|
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)
|
response = yield self.async_post('/', body)
|
||||||
data = json.loads(to_str(response.body))
|
data = json.loads(to_str(response.body))
|
||||||
self.assert_status_none(data)
|
self.assert_status_none(data)
|
||||||
@@ -880,7 +918,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
|||||||
|
|
||||||
@tornado.testing.gen_test
|
@tornado.testing.gen_test
|
||||||
def test_saved_password_is_reused_when_the_field_is_left_empty(self):
|
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)
|
response = yield self.async_post('/', body)
|
||||||
self.assert_status_none(json.loads(to_str(response.body)))
|
self.assert_status_none(json.loads(to_str(response.body)))
|
||||||
|
|
||||||
@@ -891,7 +929,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
|||||||
|
|
||||||
@tornado.testing.gen_test
|
@tornado.testing.gen_test
|
||||||
def test_saved_password_is_not_sent_to_another_destination(self):
|
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)
|
response = yield self.async_post('/', body)
|
||||||
self.assert_status_none(json.loads(to_str(response.body)))
|
self.assert_status_none(json.loads(to_str(response.body)))
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class TestPrivateKey(unittest.TestCase):
|
|||||||
with self.assertRaises(InvalidValueError) as ctx:
|
with self.assertRaises(InvalidValueError) as ctx:
|
||||||
pk.get_pkey_obj()
|
pk.get_pkey_obj()
|
||||||
self.assertIn('wrong passphrase', str(ctx.exception))
|
self.assertIn('wrong passphrase', str(ctx.exception))
|
||||||
|
self.assertNotIn('wrongpass', str(ctx.exception))
|
||||||
|
|
||||||
pk = self.get_pk_obj(fname, password=password)
|
pk = self.get_pk_obj(fname, password=password)
|
||||||
self.assertIsInstance(pk.get_pkey_obj(), klass)
|
self.assertIsInstance(pk.get_pkey_obj(), klass)
|
||||||
@@ -318,12 +319,41 @@ class TestWsockHandler(unittest.TestCase):
|
|||||||
obj.close.assert_called_with(reason='Worker closed')
|
obj.close.assert_called_with(reason='Worker closed')
|
||||||
|
|
||||||
class TestIndexHandler(unittest.TestCase):
|
class TestIndexHandler(unittest.TestCase):
|
||||||
|
def test_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):
|
def test_get_args_keeps_password_for_saved_connection(self):
|
||||||
obj = Mock(spec=IndexHandler)
|
obj = Mock(spec=IndexHandler)
|
||||||
obj.get_hostname.return_value = '127.0.0.1'
|
obj.get_hostname.return_value = '127.0.0.1'
|
||||||
obj.get_port.return_value = 22
|
obj.get_port.return_value = 22
|
||||||
obj.get_value.return_value = 'root'
|
obj.get_value.return_value = 'root'
|
||||||
obj.get_privatekey.return_value = ('', '')
|
obj.get_privatekey.return_value = ('', '')
|
||||||
|
obj.get_saved_privatekey.return_value = ('', '')
|
||||||
obj.get_directory.return_value = ''
|
obj.get_directory.return_value = ''
|
||||||
obj.policy = paramiko.WarningPolicy()
|
obj.policy = paramiko.WarningPolicy()
|
||||||
obj.ssh_client = Mock()
|
obj.ssh_client = Mock()
|
||||||
@@ -352,6 +382,7 @@ class TestIndexHandler(unittest.TestCase):
|
|||||||
obj.get_port.return_value = 22
|
obj.get_port.return_value = 22
|
||||||
obj.get_value.return_value = 'root'
|
obj.get_value.return_value = 'root'
|
||||||
obj.get_privatekey.return_value = ('', '')
|
obj.get_privatekey.return_value = ('', '')
|
||||||
|
obj.get_saved_privatekey.return_value = ('', '')
|
||||||
obj.get_directory.return_value = '/var/www'
|
obj.get_directory.return_value = '/var/www'
|
||||||
obj.policy = paramiko.WarningPolicy()
|
obj.policy = paramiko.WarningPolicy()
|
||||||
obj.ssh_client = Mock()
|
obj.ssh_client = Mock()
|
||||||
@@ -362,6 +393,21 @@ class TestIndexHandler(unittest.TestCase):
|
|||||||
self.assertEqual('/var/www', args[6])
|
self.assertEqual('/var/www', args[6])
|
||||||
self.assertEqual('/var/www', obj.connection_info['directory'])
|
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):
|
def test_get_directory(self):
|
||||||
obj = Mock(spec=IndexHandler)
|
obj = Mock(spec=IndexHandler)
|
||||||
values = {}
|
values = {}
|
||||||
|
|||||||
@@ -163,6 +163,119 @@ class TestConnectionStore(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
self.assertEqual('', profile['directory'])
|
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):
|
class TestSecretBox(unittest.TestCase):
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+30
-6
@@ -255,8 +255,7 @@ class PrivateKey(object):
|
|||||||
logging.error(str(self.last_exception))
|
logging.error(str(self.last_exception))
|
||||||
msg = 'Invalid key'
|
msg = 'Invalid key'
|
||||||
if self.password:
|
if self.password:
|
||||||
msg += ' or wrong passphrase "{}" for decrypting it.'.format(
|
msg += ' or wrong passphrase for decrypting it.'
|
||||||
self.password)
|
|
||||||
raise InvalidValueError(msg)
|
raise InvalidValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -668,6 +667,11 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
directory = self.get_directory()
|
directory = self.get_directory()
|
||||||
|
|
||||||
if not password and not privatekey:
|
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)
|
password = self.get_saved_password(hostname, port, username)
|
||||||
|
|
||||||
if isinstance(self.policy, paramiko.RejectPolicy):
|
if isinstance(self.policy, paramiko.RejectPolicy):
|
||||||
@@ -685,14 +689,35 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
'port': port,
|
'port': port,
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'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,
|
'term': term,
|
||||||
'directory': directory,
|
'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'
|
'auth_type': 'privatekey' if privatekey else 'password'
|
||||||
}
|
}
|
||||||
logging.debug(args[:5])
|
logging.debug('SSH destination: %s:%s', hostname, port)
|
||||||
|
|
||||||
return args
|
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):
|
def parse_encoding(self, data):
|
||||||
try:
|
try:
|
||||||
encoding = to_str(data.strip(), 'ascii')
|
encoding = to_str(data.strip(), 'ascii')
|
||||||
@@ -894,8 +919,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
|
|||||||
self.close(reason=str(exc))
|
self.close(reason=str(exc))
|
||||||
else:
|
else:
|
||||||
worker = workers.get(worker_id)
|
worker = workers.get(worker_id)
|
||||||
if worker:
|
if worker and not worker.handler:
|
||||||
workers[worker_id] = None
|
|
||||||
self.set_nodelay(True)
|
self.set_nodelay(True)
|
||||||
worker.set_handler(self)
|
worker.set_handler(self)
|
||||||
self.worker_ref = weakref.ref(worker)
|
self.worker_ref = weakref.ref(worker)
|
||||||
@@ -904,7 +928,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
|
|||||||
self.close(reason='Websocket authentication failed.')
|
self.close(reason='Websocket authentication failed.')
|
||||||
|
|
||||||
def on_message(self, message):
|
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()
|
worker = self.worker_ref()
|
||||||
if not worker:
|
if not worker:
|
||||||
# The worker has likely been closed. Do not process.
|
# The worker has likely been closed. Do not process.
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from webssh.settings import (
|
|||||||
get_app_settings, get_host_keys_settings, get_policy_setting,
|
get_app_settings, get_host_keys_settings, get_policy_setting,
|
||||||
get_ssl_context, get_server_settings, check_encoding_setting
|
get_ssl_context, get_server_settings, check_encoding_setting
|
||||||
)
|
)
|
||||||
|
from webssh.workspace import (
|
||||||
|
ConnectionMetadataHandler, ConnectionExportHandler,
|
||||||
|
ConnectionImportHandler, FilesHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_handlers(loop, options):
|
def make_handlers(loop, options):
|
||||||
@@ -22,7 +26,11 @@ def make_handlers(loop, options):
|
|||||||
(r'/login', LoginHandler),
|
(r'/login', LoginHandler),
|
||||||
(r'/logout', LogoutHandler),
|
(r'/logout', LogoutHandler),
|
||||||
(r'/connections', ConnectionsHandler),
|
(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'/connections/([a-f0-9]{24})', ConnectionHandler),
|
||||||
|
(r'/files/([A-Za-z0-9_-]+)', FilesHandler),
|
||||||
(r'/', IndexHandler, dict(loop=loop, policy=policy,
|
(r'/', IndexHandler, dict(loop=loop, policy=policy,
|
||||||
host_keys_settings=host_keys_settings)),
|
host_keys_settings=host_keys_settings)),
|
||||||
(r'/ws', WsockHandler, dict(loop=loop))
|
(r'/ws', WsockHandler, dict(loop=loop))
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ define('version', type=bool, help='Show version information',
|
|||||||
|
|
||||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
font_dirs = ['webssh', 'static', 'css', 'fonts']
|
font_dirs = ['webssh', 'static', 'css', 'fonts']
|
||||||
max_body_size = 1 * 1024 * 1024
|
max_body_size = 17 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class Font(object):
|
class Font(object):
|
||||||
|
|||||||
@@ -216,9 +216,85 @@ input[type="file"].form-control {
|
|||||||
|
|
||||||
.save-field {
|
.save-field {
|
||||||
display: flex;
|
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 {
|
.save-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
+117
-352
@@ -71,6 +71,7 @@ jQuery(function($){
|
|||||||
saved_connections = [],
|
saved_connections = [],
|
||||||
saved_connection_map = {},
|
saved_connection_map = {},
|
||||||
validated_form_data,
|
validated_form_data,
|
||||||
|
pending_connection,
|
||||||
event_origin,
|
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*$)/;
|
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];
|
profile = saved_connections[i];
|
||||||
saved_connection_map[profile.id] = profile;
|
saved_connection_map[profile.id] = profile;
|
||||||
}
|
}
|
||||||
|
var group = $('#connection-group').val(), groups = [];
|
||||||
|
$('#connection-group').empty().append($('<option>').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($('<option>').val(value).text(value)); });
|
||||||
|
$('#connection-group').val(groups.indexOf(group) >= 0 ? group : '');
|
||||||
render_saved_connections();
|
render_saved_connections();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +138,10 @@ jQuery(function($){
|
|||||||
|
|
||||||
for (i = 0; i < saved_connections.length; i++) {
|
for (i = 0; i < saved_connections.length; i++) {
|
||||||
profile = saved_connections[i];
|
profile = saved_connections[i];
|
||||||
option = $('<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 = $('<option>').val(profile.id).text((profile.group ? '[' + profile.group + '] ' : '') + (profile.name || profile.title));
|
||||||
saved_connections_select.append(option);
|
saved_connections_select.append(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +171,9 @@ jQuery(function($){
|
|||||||
}
|
}
|
||||||
|
|
||||||
saved_connection_map[profile.id] = profile;
|
saved_connection_map[profile.id] = profile;
|
||||||
|
set_saved_connections(saved_connections);
|
||||||
render_saved_connections(profile.id);
|
render_saved_connections(profile.id);
|
||||||
|
update_saved_key_hint(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -198,6 +211,7 @@ jQuery(function($){
|
|||||||
var profile = selected_connection();
|
var profile = selected_connection();
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
apply_saved_password_hint(false);
|
apply_saved_password_hint(false);
|
||||||
|
update_saved_key_hint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +222,31 @@ jQuery(function($){
|
|||||||
$('#passphrase').val('');
|
$('#passphrase').val('');
|
||||||
$('#totp').val('');
|
$('#totp').val('');
|
||||||
$('#directory').val(profile.directory || '');
|
$('#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');
|
term_type.val(profile.term || 'xterm-256color');
|
||||||
apply_saved_password_hint(profile.has_password);
|
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('');
|
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) {
|
function apply_saved_password_hint(has_password) {
|
||||||
// the server never sends saved passwords to the browser; an empty
|
// the server never sends saved passwords to the browser; an empty
|
||||||
// field just tells it to use the one it already has on disk
|
// field just tells it to use the one it already has on disk
|
||||||
@@ -243,6 +276,8 @@ jQuery(function($){
|
|||||||
return item.id !== profile.id;
|
return item.id !== profile.id;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
apply_saved_password_hint(false);
|
||||||
|
update_saved_key_hint(null);
|
||||||
},
|
},
|
||||||
error: function(resp) {
|
error: function(resp) {
|
||||||
log_status(resp.status + ': ' + resp.statusText);
|
log_status(resp.status + ': ' + resp.statusText);
|
||||||
@@ -579,6 +614,7 @@ jQuery(function($){
|
|||||||
|
|
||||||
function log_status(text, to_populate) {
|
function log_status(text, to_populate) {
|
||||||
text = translate_status(text);
|
text = translate_status(text);
|
||||||
|
if (to_populate) { $('#workspace').removeClass('workspace-fullscreen'); }
|
||||||
if (text) {
|
if (text) {
|
||||||
console.log(text);
|
console.log(text);
|
||||||
}
|
}
|
||||||
@@ -657,362 +693,58 @@ jQuery(function($){
|
|||||||
|
|
||||||
function ajax_complete_callback(resp) {
|
function ajax_complete_callback(resp) {
|
||||||
button.prop('disabled', false);
|
button.prop('disabled', false);
|
||||||
|
state = DISCONNECTED;
|
||||||
if (resp.status !== 200) {
|
waiter.hide();
|
||||||
log_status(resp.status + ': ' + resp.statusText, true);
|
|
||||||
state = DISCONNECTED;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var msg = resp.responseJSON;
|
var msg = resp.responseJSON;
|
||||||
if (!msg.id) {
|
if (resp.status !== 200 || !msg || !msg.id) {
|
||||||
log_status(msg.status, true);
|
log_status(msg && msg.status || resp.status + ': ' + resp.statusText, true);
|
||||||
state = DISCONNECTED;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.profile) { upsert_saved_connection(msg.profile); }
|
||||||
if (msg.profile) {
|
status.text('');
|
||||||
upsert_saved_connection(msg.profile);
|
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,
|
||||||
var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'),
|
font: update_font_family,
|
||||||
join = (ws_url[ws_url.length-1] === '/' ? '' : '/'),
|
reconnect: function(connection) {
|
||||||
url = ws_url + join + 'ws?id=' + msg.id,
|
$('#workspace').removeClass('workspace-fullscreen');
|
||||||
sock = new window.WebSocket(url),
|
$('#hostname').val(connection.hostname);
|
||||||
encoding = 'utf-8',
|
$('#port').val(connection.port);
|
||||||
decoder = window.TextDecoder ? new window.TextDecoder(encoding) : encoding,
|
$('#username').val(connection.username);
|
||||||
terminal = document.getElementById('terminal'),
|
$('#directory').val(connection.directory || '');
|
||||||
termOptions = {
|
$('#connection-name').val(connection.name || '');
|
||||||
cursorBlink: true,
|
$('#connection-group-name').val(connection.group || '');
|
||||||
theme: {
|
$('#connection-notes').val(connection.notes || '');
|
||||||
background: url_opts_data.bgcolor || '#0f1720',
|
$('#password, #privatekey, #passphrase, #totp').val('');
|
||||||
foreground: url_opts_data.fontcolor || '#d9f1ee',
|
var id, profile;
|
||||||
cursor: url_opts_data.cursor || url_opts_data.fontcolor || '#55c7ba'
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
form_container.show();
|
||||||
}
|
log_status('请补充密码、私钥或动态验证码后连接。');
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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) {
|
if (pk && pk.size && !debug) {
|
||||||
read_file_as_text(pk, function(text) {
|
read_file_as_text(pk, function(text) {
|
||||||
if (text === undefined) {
|
if (text === undefined) {
|
||||||
|
state = DISCONNECTED;
|
||||||
log_status('私钥文件无效:' + pk.name);
|
log_status('私钥文件无效:' + pk.name);
|
||||||
} else {
|
} else {
|
||||||
ajax_post();
|
ajax_post();
|
||||||
@@ -1210,7 +943,7 @@ jQuery(function($){
|
|||||||
// for console use
|
// for console use
|
||||||
var result, opts;
|
var result, opts;
|
||||||
|
|
||||||
if (state !== DISCONNECTED) {
|
if (state === CONNECTING) {
|
||||||
console.log(messages[state]);
|
console.log(messages[state]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1236,7 +969,12 @@ jQuery(function($){
|
|||||||
result = connect_with_options(opts);
|
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;
|
state = CONNECTING;
|
||||||
default_title = result.title;
|
default_title = result.title;
|
||||||
if (hostname) {
|
if (hostname) {
|
||||||
@@ -1258,9 +996,24 @@ jQuery(function($){
|
|||||||
update_password_visibility();
|
update_password_visibility();
|
||||||
saved_connections_select.val('');
|
saved_connections_select.val('');
|
||||||
apply_saved_password_hint(false);
|
apply_saved_password_hint(false);
|
||||||
|
update_saved_key_hint(null);
|
||||||
}, 0);
|
}, 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() {
|
load_connection_button.click(function() {
|
||||||
apply_saved_connection();
|
apply_saved_connection();
|
||||||
});
|
});
|
||||||
@@ -1269,6 +1022,18 @@ jQuery(function($){
|
|||||||
apply_saved_connection();
|
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_connection_button.click(function() {
|
||||||
delete_saved_connection();
|
delete_saved_connection();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = $('<button type="button" role="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($('<option>').val('').text('选择常用命令'));
|
||||||
|
commands.forEach(function(command, index) {
|
||||||
|
$('#command-list').append($('<option>').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 = $('<button type="button" class="file-entry">').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));
|
||||||
+160
-2
@@ -5,12 +5,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from webssh.crypto import SecretBox, is_encrypted
|
from webssh.crypto import SecretBox, is_encrypted
|
||||||
|
from webssh.utils import (
|
||||||
|
is_valid_directory, is_valid_hostname, is_valid_ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PORT = 22
|
DEFAULT_PORT = 22
|
||||||
|
|
||||||
# keys that must never be handed out to a browser
|
# 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):
|
class ConnectionStore(object):
|
||||||
@@ -51,6 +57,141 @@ class ConnectionStore(object):
|
|||||||
def make_id(self, owner, hostname, port, username):
|
def make_id(self, owner, hostname, port, username):
|
||||||
return self._make_id(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):
|
def upsert(self, owner, connection):
|
||||||
profile = self._normalize(owner, connection)
|
profile = self._normalize(owner, connection)
|
||||||
|
|
||||||
@@ -121,6 +262,8 @@ class ConnectionStore(object):
|
|||||||
public['has_password'] = bool(
|
public['has_password'] = bool(
|
||||||
profile.get('password_enc') or profile.get('password')
|
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
|
return public
|
||||||
|
|
||||||
def _normalize(self, owner, connection):
|
def _normalize(self, owner, connection):
|
||||||
@@ -148,13 +291,28 @@ class ConnectionStore(object):
|
|||||||
'directory': directory,
|
'directory': directory,
|
||||||
'auth_type': auth_type
|
'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:
|
if self.box:
|
||||||
profile['password_enc'] = self.box.encrypt(password)
|
profile['password_enc'] = self.box.encrypt(password)
|
||||||
else:
|
else:
|
||||||
# no server secret to encrypt with; keep the legacy layout so
|
# no server secret to encrypt with; keep the legacy layout so
|
||||||
# encrypt_plaintext_passwords() can upgrade it later
|
# encrypt_plaintext_passwords() can upgrade it later
|
||||||
profile['password'] = password
|
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
|
return profile
|
||||||
|
|
||||||
def _make_id(self, owner, hostname, port, username):
|
def _make_id(self, owner, hostname, port, username):
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
|
|
||||||
<div class="saved-bar">
|
<div class="saved-bar">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<input class="form-control" id="connection-search" type="search"
|
||||||
|
aria-label="搜索连接" placeholder="搜索名称、主机、备注">
|
||||||
|
<select class="form-control" id="connection-group" aria-label="按分组筛选">
|
||||||
|
<option value="">全部分组</option>
|
||||||
|
</select>
|
||||||
<label for="saved-connections">已保存连接</label>
|
<label for="saved-connections">已保存连接</label>
|
||||||
<select class="form-control" id="saved-connections">
|
<select class="form-control" id="saved-connections">
|
||||||
<option value="">未选择保存的连接</option>
|
<option value="">未选择保存的连接</option>
|
||||||
@@ -60,11 +65,28 @@
|
|||||||
id="delete-connection">
|
id="delete-connection">
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="edit-connection">保存备注</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="export-config">导出配置</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="backup-credentials">加密备份</button>
|
||||||
|
<label class="btn btn-secondary" for="import-config">导入配置 / 备份</label>
|
||||||
|
<input type="file" id="import-config" accept="application/json,.json" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="connect" class="connection-form" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
<form id="connect" class="connection-form" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="connection-name">连接名称</label>
|
||||||
|
<input class="form-control" id="connection-name" name="name" maxlength="100" placeholder="例如:生产服务器">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="connection-group-name">分组</label>
|
||||||
|
<input class="form-control" id="connection-group-name" name="group" maxlength="100" placeholder="例如:生产环境">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="connection-notes">备注</label>
|
||||||
|
<textarea class="form-control" id="connection-notes" name="notes" maxlength="1000" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hostname">主机地址</label>
|
<label for="hostname">主机地址</label>
|
||||||
<input class="form-control" type="text" id="hostname"
|
<input class="form-control" type="text" id="hostname"
|
||||||
@@ -95,6 +117,7 @@
|
|||||||
<label for="privatekey">私钥文件</label>
|
<label for="privatekey">私钥文件</label>
|
||||||
<input class="form-control" type="file" id="privatekey"
|
<input class="form-control" type="file" id="privatekey"
|
||||||
name="privatekey" value="">
|
name="privatekey" value="">
|
||||||
|
<small class="field-hint" id="privatekey-hint"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="passphrase">私钥口令</label>
|
<label for="passphrase">私钥口令</label>
|
||||||
@@ -120,6 +143,22 @@
|
|||||||
<span>保存连接信息</span>
|
<span>保存连接信息</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field save-field">
|
||||||
|
<label class="save-toggle" for="save-credentials">
|
||||||
|
<input type="checkbox" id="save-credentials"
|
||||||
|
name="save_credentials" value="1">
|
||||||
|
<span>保存密码 / 私钥(加密)</span>
|
||||||
|
</label>
|
||||||
|
<small class="field-hint">取消后,成功连接并保存时清除原有凭据</small>
|
||||||
|
</div>
|
||||||
|
<div class="field save-field">
|
||||||
|
<label class="save-toggle" for="save-passphrase">
|
||||||
|
<input type="checkbox" id="save-passphrase"
|
||||||
|
name="save_passphrase" value="1">
|
||||||
|
<span>同时保存私钥口令(加密)</span>
|
||||||
|
</label>
|
||||||
|
<small class="field-hint">不保存时,每次连接需输入口令;动态验证码不保存</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="term" name="term" value="xterm-256color">
|
<input type="hidden" id="term" name="term" value="xterm-256color">
|
||||||
@@ -132,12 +171,68 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section class="terminal-stage">
|
<section class="terminal-stage" id="workspace">
|
||||||
<div id="status" class="status-message"></div>
|
<div id="status" class="status-message"></div>
|
||||||
|
<div id="session-tools" hidden>
|
||||||
|
<div id="session-tabs" role="tablist" aria-label="终端会话"></div>
|
||||||
|
<div class="workspace-toolbar">
|
||||||
|
<button type="button" id="new-session">新建连接</button>
|
||||||
|
<button type="button" id="reconnect-session">重新连接</button>
|
||||||
|
<button type="button" id="close-session">关闭标签</button>
|
||||||
|
<button type="button" id="toggle-search">搜索输出</button>
|
||||||
|
<button type="button" id="toggle-settings">终端设置</button>
|
||||||
|
<button type="button" id="toggle-commands">常用命令</button>
|
||||||
|
<button type="button" id="toggle-files">文件管理</button>
|
||||||
|
<button type="button" id="toggle-fullscreen">全屏</button>
|
||||||
|
</div>
|
||||||
|
<div id="search-panel" class="tool-panel" hidden>
|
||||||
|
<input id="terminal-search" type="search" placeholder="搜索终端输出" aria-label="搜索终端输出">
|
||||||
|
<button type="button" id="search-prev">上一处</button>
|
||||||
|
<button type="button" id="search-next">下一处</button>
|
||||||
|
<span id="search-result" role="status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="settings-panel" class="tool-panel" hidden>
|
||||||
|
<label>字号 <input id="terminal-fontsize" type="number" min="10" max="32" value="14"></label>
|
||||||
|
<label>主题 <select id="terminal-theme"><option value="dark">深色</option><option value="light">浅色</option><option value="green">护眼绿</option></select></label>
|
||||||
|
<label>光标 <select id="terminal-cursor"><option value="block">方块</option><option value="bar">竖线</option><option value="underline">下划线</option></select></label>
|
||||||
|
<label><input id="terminal-blink" type="checkbox" checked>光标闪烁</label>
|
||||||
|
<button type="button" id="save-terminal-settings">应用并记住</button>
|
||||||
|
</div>
|
||||||
|
<div id="commands-panel" class="tool-panel" hidden>
|
||||||
|
<select id="command-list" aria-label="常用命令"></select>
|
||||||
|
<input id="command-name" maxlength="100" placeholder="命令名称" aria-label="命令名称">
|
||||||
|
<textarea id="command-text" rows="2" maxlength="4096" placeholder="命令内容" aria-label="命令内容"></textarea>
|
||||||
|
<button type="button" id="save-command">保存</button>
|
||||||
|
<button type="button" id="delete-command">删除</button>
|
||||||
|
<button type="button" id="insert-command">填入终端</button>
|
||||||
|
<small>填入后由你按回车执行;收藏保存在当前浏览器中,请勿包含密码。</small>
|
||||||
|
</div>
|
||||||
|
<div id="files-panel" class="tool-panel" hidden>
|
||||||
|
<input id="file-path" value="." aria-label="远端目录">
|
||||||
|
<button type="button" id="file-refresh">打开 / 刷新</button>
|
||||||
|
<button type="button" id="file-parent">上一级</button>
|
||||||
|
<label>上传文件(最大 16 MiB,不覆盖同名文件)<input id="file-upload" type="file"></label>
|
||||||
|
<span id="file-status" role="status"></span>
|
||||||
|
<div id="file-list"></div>
|
||||||
|
<small>连接中断时上传可能留下未完成文件,请在远端确认。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="terminal"></div>
|
<div id="terminal"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="terminal-toast" class="terminal-toast" role="status"></div>
|
<div id="terminal-toast" class="terminal-toast" role="status"></div>
|
||||||
|
<div id="backup-password-dialog" class="password-overlay" hidden>
|
||||||
|
<form id="backup-password-form" class="password-card" role="dialog"
|
||||||
|
aria-modal="true" aria-labelledby="backup-password-title">
|
||||||
|
<label id="backup-password-title" for="backup-password">备份口令</label>
|
||||||
|
<input id="backup-password" class="form-control" type="password"
|
||||||
|
autocomplete="off" maxlength="1024" required>
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-primary" type="submit">继续</button>
|
||||||
|
<button class="btn btn-secondary" id="cancel-backup-password" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="quickbar" class="quickbar" style="display: none">
|
<div id="quickbar" class="quickbar" style="display: none">
|
||||||
<button type="button" class="quickbar-btn" data-action="copy">复制</button>
|
<button type="button" class="quickbar-btn" data-action="copy">复制</button>
|
||||||
@@ -162,5 +257,6 @@
|
|||||||
<script src="{{ static_url('js/xterm.min.js') }}"></script>
|
<script src="{{ static_url('js/xterm.min.js') }}"></script>
|
||||||
<script src="{{ static_url('js/xterm-addon-fit.min.js') }}"></script>
|
<script src="{{ static_url('js/xterm-addon-fit.min.js') }}"></script>
|
||||||
<script src="{{ static_url('js/main.js') }}"></script>
|
<script src="{{ static_url('js/main.js') }}"></script>
|
||||||
|
<script src="{{ static_url('js/workspace.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-3
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
try:
|
try:
|
||||||
import secrets
|
import secrets
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -47,6 +48,7 @@ class Worker(object):
|
|||||||
self.closed = False
|
self.closed = False
|
||||||
self.src_addr = None
|
self.src_addr = None
|
||||||
self.client_key = None
|
self.client_key = None
|
||||||
|
self.file_lock = threading.Lock()
|
||||||
|
|
||||||
def __call__(self, fd, events):
|
def __call__(self, fd, events):
|
||||||
if events & IOLoop.READ:
|
if events & IOLoop.READ:
|
||||||
@@ -80,12 +82,10 @@ class Worker(object):
|
|||||||
if self.chan.closed or errno_from_exception(e) in _ERRNO_CONNRESET:
|
if self.chan.closed or errno_from_exception(e) in _ERRNO_CONNRESET:
|
||||||
self.close(reason='chan error on reading')
|
self.close(reason='chan error on reading')
|
||||||
else:
|
else:
|
||||||
logging.debug('{!r} from {}:{}'.format(data, *self.dst_addr))
|
|
||||||
if not data:
|
if not data:
|
||||||
self.close(reason='chan closed')
|
self.close(reason='chan closed')
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.debug('{!r} to {}:{}'.format(data, *self.handler.src_addr))
|
|
||||||
try:
|
try:
|
||||||
self.handler.write_message(data, binary=True)
|
self.handler.write_message(data, binary=True)
|
||||||
except tornado.websocket.WebSocketClosedError:
|
except tornado.websocket.WebSocketClosedError:
|
||||||
@@ -97,7 +97,6 @@ class Worker(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
data = ''.join(self.data_to_dst)
|
data = ''.join(self.data_to_dst)
|
||||||
logging.debug('{!r} to {}:{}'.format(data, *self.dst_addr))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sent = self.chan.send(data)
|
sent = self.chan.send(data)
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Saved connection backups and bounded SFTP operations."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import stat
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
import tornado.gen
|
||||||
|
import tornado.web
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
from webssh.handler import IndexHandler, MixinHandler
|
||||||
|
from webssh.worker import clients
|
||||||
|
|
||||||
|
|
||||||
|
TRANSFER_LIMIT = 16 * 1024 * 1024
|
||||||
|
BACKUP_AAD = b'webssh-backup-v1'
|
||||||
|
|
||||||
|
|
||||||
|
def backup_key(password, salt):
|
||||||
|
if not isinstance(password, str) or not 10 <= len(password) <= 1024:
|
||||||
|
raise ValueError('备份口令长度需为 10 至 1024 个字符。')
|
||||||
|
return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt,
|
||||||
|
600000, dklen=32)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_backup(connections, password):
|
||||||
|
salt, nonce = os.urandom(16), os.urandom(12)
|
||||||
|
plaintext = json.dumps(connections).encode('utf-8')
|
||||||
|
encrypted = AESGCM(backup_key(password, salt)).encrypt(
|
||||||
|
nonce, plaintext, BACKUP_AAD
|
||||||
|
)
|
||||||
|
return dict(format='webssh-encrypted', version=1,
|
||||||
|
salt=base64.b64encode(salt).decode('ascii'),
|
||||||
|
nonce=base64.b64encode(nonce).decode('ascii'),
|
||||||
|
data=base64.b64encode(encrypted).decode('ascii'))
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_backup(document, password):
|
||||||
|
try:
|
||||||
|
if document.get('version') != 1:
|
||||||
|
raise ValueError()
|
||||||
|
salt = base64.b64decode(document['salt'], validate=True)
|
||||||
|
nonce = base64.b64decode(document['nonce'], validate=True)
|
||||||
|
data = base64.b64decode(document['data'], validate=True)
|
||||||
|
if len(salt) != 16 or len(nonce) != 12:
|
||||||
|
raise ValueError()
|
||||||
|
plaintext = AESGCM(backup_key(password, salt)).decrypt(
|
||||||
|
nonce, data, BACKUP_AAD
|
||||||
|
)
|
||||||
|
return json.loads(plaintext)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('备份口令错误或文件已损坏。') from None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceHandler(MixinHandler, tornado.web.RequestHandler):
|
||||||
|
executor = IndexHandler.executor
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
super(WorkspaceHandler, self).initialize()
|
||||||
|
|
||||||
|
def check_workspace_origin(self):
|
||||||
|
origin = self.request.headers.get('Origin')
|
||||||
|
if origin and not self.check_origin(origin):
|
||||||
|
raise tornado.web.HTTPError(403)
|
||||||
|
|
||||||
|
def read_json(self):
|
||||||
|
try:
|
||||||
|
data = json.loads(self.request.body)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError()
|
||||||
|
return data
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
raise tornado.web.HTTPError(400, 'JSON 格式无效。')
|
||||||
|
|
||||||
|
def set_default_headers(self):
|
||||||
|
super(WorkspaceHandler, self).set_default_headers()
|
||||||
|
self.set_header('Cache-Control', 'no-store')
|
||||||
|
self.set_header('X-Content-Type-Options', 'nosniff')
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionMetadataHandler(WorkspaceHandler):
|
||||||
|
@tornado.web.authenticated
|
||||||
|
@tornado.gen.coroutine
|
||||||
|
def post(self, profile_id):
|
||||||
|
self.check_workspace_origin()
|
||||||
|
metadata = self.read_json()
|
||||||
|
try:
|
||||||
|
profile = yield self.executor.submit(
|
||||||
|
self.connection_store.update_metadata,
|
||||||
|
self.current_user, profile_id, metadata
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise tornado.web.HTTPError(400, str(exc))
|
||||||
|
if profile is None:
|
||||||
|
raise tornado.web.HTTPError(404)
|
||||||
|
self.write({'profile': profile})
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionExportHandler(WorkspaceHandler):
|
||||||
|
@tornado.web.authenticated
|
||||||
|
@tornado.gen.coroutine
|
||||||
|
def post(self):
|
||||||
|
self.check_workspace_origin()
|
||||||
|
data = self.read_json()
|
||||||
|
credentials = data.get('credentials') is True
|
||||||
|
|
||||||
|
def export():
|
||||||
|
profiles = self.connection_store.export_connections(
|
||||||
|
self.current_user, credentials
|
||||||
|
)
|
||||||
|
if credentials:
|
||||||
|
return encrypt_backup(profiles, data.get('password'))
|
||||||
|
return dict(format='webssh-config', version=1,
|
||||||
|
connections=profiles)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = yield self.executor.submit(export)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise tornado.web.HTTPError(400, str(exc))
|
||||||
|
self.write(document)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionImportHandler(WorkspaceHandler):
|
||||||
|
@tornado.web.authenticated
|
||||||
|
@tornado.gen.coroutine
|
||||||
|
def post(self):
|
||||||
|
self.check_workspace_origin()
|
||||||
|
data = self.read_json()
|
||||||
|
document = data.get('document')
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise tornado.web.HTTPError(400, '配置格式无效。')
|
||||||
|
|
||||||
|
def restore():
|
||||||
|
credentials = document.get('format') == 'webssh-encrypted'
|
||||||
|
if credentials:
|
||||||
|
connections = decrypt_backup(document, data.get('password'))
|
||||||
|
elif (document.get('format') == 'webssh-config' and
|
||||||
|
document.get('version') == 1):
|
||||||
|
connections = document.get('connections')
|
||||||
|
else:
|
||||||
|
raise ValueError('不支持的配置格式。')
|
||||||
|
return self.connection_store.import_connections(
|
||||||
|
self.current_user, connections, credentials
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = yield self.executor.submit(restore)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise tornado.web.HTTPError(400, str(exc))
|
||||||
|
self.write(dict(imported=count,
|
||||||
|
connections=self.connection_store.list(
|
||||||
|
self.current_user)))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_path(path):
|
||||||
|
if (not isinstance(path, str) or len(path) > 4096 or
|
||||||
|
any(ord(char) < 32 or ord(char) == 127 for char in path)):
|
||||||
|
raise ValueError('文件路径无效。')
|
||||||
|
return path or '.'
|
||||||
|
|
||||||
|
|
||||||
|
def transfer(ssh, action, path, upload=None):
|
||||||
|
"""Runs exclusively in the thread pool; each operation owns its channel."""
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
try:
|
||||||
|
sftp.get_channel().settimeout(30)
|
||||||
|
path = sftp.normalize(validate_path(path))
|
||||||
|
if action == 'list':
|
||||||
|
entries = []
|
||||||
|
for attr in sftp.listdir_iter(path):
|
||||||
|
if len(entries) >= 5000:
|
||||||
|
raise ValueError('目录超过 5000 项,请使用终端查看。')
|
||||||
|
entries.append(dict(name=attr.filename,
|
||||||
|
directory=stat.S_ISDIR(attr.st_mode or 0),
|
||||||
|
size=attr.st_size or 0,
|
||||||
|
modified=attr.st_mtime or 0))
|
||||||
|
entries.sort(key=lambda item: (not item['directory'], item['name']))
|
||||||
|
return dict(path=path, entries=entries)
|
||||||
|
if action == 'download':
|
||||||
|
attr = sftp.stat(path)
|
||||||
|
if not stat.S_ISREG(attr.st_mode or 0):
|
||||||
|
raise ValueError('只能下载普通文件。')
|
||||||
|
if attr.st_size > TRANSFER_LIMIT:
|
||||||
|
raise ValueError('单个文件最大为 16 MiB。')
|
||||||
|
with sftp.open(path, 'rb') as remote:
|
||||||
|
content = remote.read(TRANSFER_LIMIT + 1)
|
||||||
|
if len(content) > TRANSFER_LIMIT:
|
||||||
|
raise ValueError('单个文件最大为 16 MiB。')
|
||||||
|
return content
|
||||||
|
filename, content = upload
|
||||||
|
if (not filename or filename in ('.', '..') or
|
||||||
|
'/' in filename or '\\' in filename):
|
||||||
|
raise ValueError('上传文件名无效。')
|
||||||
|
validate_path(filename)
|
||||||
|
if len(content) > TRANSFER_LIMIT:
|
||||||
|
raise ValueError('单个文件最大为 16 MiB。')
|
||||||
|
target = posixpath.join(path, filename)
|
||||||
|
# Exclusive creation prevents unintended replacement of remote files.
|
||||||
|
with sftp.open(target, 'wx') as remote:
|
||||||
|
for offset in range(0, len(content), 32768):
|
||||||
|
remote.write(content[offset:offset + 32768])
|
||||||
|
return dict(status='ok')
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FilesHandler(WorkspaceHandler):
|
||||||
|
def get_worker(self, worker_id):
|
||||||
|
worker = clients.get(self.get_client_key(), {}).get(worker_id)
|
||||||
|
if (not worker or worker.closed or not worker.handler or
|
||||||
|
not worker.file_lock.acquire(False)):
|
||||||
|
raise tornado.web.HTTPError(409, '会话已断开或正在传输文件。')
|
||||||
|
return worker
|
||||||
|
|
||||||
|
@tornado.web.authenticated
|
||||||
|
@tornado.gen.coroutine
|
||||||
|
def get(self, worker_id):
|
||||||
|
self.check_workspace_origin()
|
||||||
|
worker = self.get_worker(worker_id)
|
||||||
|
download = self.get_argument('download', '') == '1'
|
||||||
|
path = self.get_argument('path', '.')
|
||||||
|
try:
|
||||||
|
result = yield self.executor.submit(
|
||||||
|
transfer, worker.ssh, 'download' if download else 'list', path
|
||||||
|
)
|
||||||
|
except (OSError, paramiko.SSHException, ValueError):
|
||||||
|
raise tornado.web.HTTPError(
|
||||||
|
400, '文件操作失败,请检查路径、权限及 SFTP 支持。'
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
worker.file_lock.release()
|
||||||
|
if download:
|
||||||
|
self.set_header('Content-Type', 'application/octet-stream')
|
||||||
|
self.set_header('Content-Disposition', 'attachment')
|
||||||
|
self.write(result)
|
||||||
|
else:
|
||||||
|
self.write(result)
|
||||||
|
|
||||||
|
@tornado.web.authenticated
|
||||||
|
@tornado.gen.coroutine
|
||||||
|
def post(self, worker_id):
|
||||||
|
self.check_workspace_origin()
|
||||||
|
files = self.request.files.get('file', [])
|
||||||
|
if len(files) != 1:
|
||||||
|
raise tornado.web.HTTPError(400, '请选择一个文件。')
|
||||||
|
worker = self.get_worker(worker_id)
|
||||||
|
try:
|
||||||
|
result = yield self.executor.submit(
|
||||||
|
transfer, worker.ssh, 'upload', self.get_argument('path', '.'),
|
||||||
|
(files[0]['filename'], files[0]['body'])
|
||||||
|
)
|
||||||
|
except (OSError, paramiko.SSHException, ValueError):
|
||||||
|
raise tornado.web.HTTPError(
|
||||||
|
400, '上传失败:同名文件已存在、权限不足或连接中断。'
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
worker.file_lock.release()
|
||||||
|
self.write(result)
|
||||||
Reference in New Issue
Block a user