feat: add saved SSH keys and terminal workspace tools

This commit is contained in:
Jocay
2026-10-05 00:58:22 +08:00
parent 0ea184f8e1
commit 3580ded915
15 changed files with 1606 additions and 373 deletions
+41 -4
View File
@@ -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
View File
@@ -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)))
+46
View File
@@ -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 = {}
+113
View File
@@ -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):
+177
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -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):
+77 -1
View File
@@ -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
View File
@@ -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();
}); });
+435
View File
@@ -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
View File
@@ -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):
+97 -1
View File
@@ -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
View File
@@ -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)
+261
View File
@@ -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)