Compare commits

...

11 Commits

Author SHA1 Message Date
Jocay 0f42cb3c6d fix: improve terminal clipboard interactions 2026-08-13 00:20:10 +08:00
Jocay 6f6c554c90 fix: explicitly specify docker.io registry for Python base image to bypass private mirror 2026-08-10 00:31:15 +08:00
Jocay 88d3838aa7 chore: remove temporary commit message file 2026-08-10 00:08:04 +08:00
Jocay c013a389fe feat: Implement directory validation and shell command quoting
- Added is_valid_directory function to validate directory paths, ensuring they do not contain control characters and are within a specified length.
- Introduced quote_shell_arg to safely quote shell arguments, preventing command injection.
- Created build_cd_command to generate a command for changing directories in a shell.
- Enhanced the LoginHandler to utilize a login rate limiter, preventing brute-force attacks by tracking failed login attempts.
- Implemented an EncodingCache to optimize encoding detection for SSH connections.
- Updated the UI to include an input field for specifying an initial directory upon login, with appropriate validation and hints.
- Added a quickbar in the terminal interface for easy access to copy and paste functionality.
- Introduced a toast notification system to provide feedback on copy actions.
- Refactored connection storage to encrypt passwords at rest, improving security.
- Updated various templates and styles to accommodate new features and improve user experience.
2026-08-10 00:07:52 +08:00
jocayn af50427169 Ignore unrelated postMessage payloads 2026-06-13 14:52:35 +08:00
jocayn e7b771f10f Keep terminal visible after idle disconnects 2026-06-13 13:38:11 +08:00
jocayn f5369b7ab9 Save SSH passwords with connections 2026-06-13 13:10:16 +08:00
jocayn 51b6f9ecfd Measure terminal from fullscreen container 2026-06-13 12:50:39 +08:00
jocayn b5307baca5 Lift fullscreen terminal above bottom chrome 2026-06-13 12:45:16 +08:00
jocayn 47cb0123e6 Reserve bottom space for fullscreen terminal 2026-06-13 12:39:48 +08:00
jocayn a7d435e59e Allow overriding Docker base image 2026-06-13 12:22:13 +08:00
22 changed files with 1693 additions and 82 deletions
+70
View File
@@ -0,0 +1,70 @@
# Repository guidance
## Project overview
WebSSH is a Python 3.10+ web SSH client. Tornado serves the HTTP and WebSocket
endpoints, Paramiko manages SSH connections, and xterm.js renders the terminal
in the browser.
## Repository layout
- `webssh/main.py`: application entry point and route wiring.
- `webssh/handler.py`: HTTP/WebSocket handlers and SSH connection setup.
- `webssh/worker.py`: asynchronous relay between Tornado and Paramiko channels.
- `webssh/settings.py`: CLI options and application/server configuration.
- `webssh/auth.py`, `webssh/storage.py`, `webssh/crypto.py`: authentication and
persistent saved-connection data.
- `webssh/templates/` and `webssh/static/`: browser UI.
- `tests/`: pytest/unittest test suite; `tests/sshserver.py` provides the local
Paramiko-based integration server.
## Setup and development commands
Use Python 3.10, 3.11, or 3.12.
```bash
python -m pip install -r requirements.txt
python -m pip install -e .
python run.py # listens on 127.0.0.1:8888
python run.py --port=8888 --debug=True --data-dir=./data
```
Install test tools with `python -m pip install pytest pytest-cov ruff`. Before
handing off a change, run the checks relevant to it:
```bash
python -m pytest tests
python -m pytest tests/test_app.py # focused example
python -m pytest --cov=webssh
ruff check .
```
The legacy Flake8 configuration in `setup.cfg` uses a maximum line length of
79 and excludes tests and `__init__.py` files.
## Implementation constraints
- Do not block Tornado's IOLoop. Paramiko connection work is blocking and must
run through the existing handler `ThreadPoolExecutor` pattern.
- Preserve locking around auth data, saved connections, and host-key mutation;
these objects are accessed from multiple threads.
- New HTTP endpoints should include `MixinHandler`. Protect private endpoints
with `@tornado.web.authenticated` and retain the existing origin, XSRF, IP,
and session checks.
- Treat credentials, private keys, cookie secrets, and saved connection data as
sensitive. Never log or expose them to the browser. Persistent auth and
connection files must retain mode `0o600`.
- Keep saved-password encryption compatible with the cookie secret and do not
silently reuse a credential when host, port, or username changes.
- Keep changes compatible with all supported Python versions and avoid adding
dependencies unless the task requires them.
## Testing expectations
- Add or update focused tests for behavior changes and regressions.
- Use the local mock SSH server for SSH authentication and connection flows;
tests must not depend on external SSH hosts or network services.
- For frontend or WebSocket changes, cover both the server handler behavior and
the corresponding browser-side message contract when applicable.
- Do not commit generated runtime state such as `auth.json`, `connections.json`,
cookie secrets, coverage output, caches, or local data directories.
+6 -2
View File
@@ -1,4 +1,5 @@
FROM python:3.12-slim
ARG PYTHON_IMAGE=python:3.12-slim
FROM ${PYTHON_IMAGE}
LABEL maintainer='<author>'
LABEL version='0.0.0-dev.0-build.0'
@@ -18,4 +19,7 @@ RUN useradd --system --create-home --shell /usr/sbin/nologin webssh && \
EXPOSE 8888/tcp
USER webssh
CMD ["python", "run.py", "--address=0.0.0.0", "--port=8888", "--data-dir=/data"]
# WEBSSH_OPTS passes extra wssh options through without rebuilding the image,
# e.g. WEBSSH_OPTS="--maxconn=50 --tdstream=172.18.0.1"
ENV WEBSSH_OPTS=""
CMD ["sh", "-c", "exec python run.py --address=0.0.0.0 --port=8888 --data-dir=/data $WEBSSH_OPTS"]
+35 -3
View File
@@ -63,8 +63,8 @@ WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
```
Saved connections are stored under the data directory too. WebSSH saves
hostname, port, username and terminal type for quick reconnects. It does not
persist ssh passwords, private keys, key passphrases or TOTP codes.
hostname, port, username, ssh password and terminal type for quick reconnects.
It does not persist private keys, key passphrases or TOTP codes.
Use `--auth=false` only for a trusted private deployment where another layer
already protects access to WebSSH.
@@ -109,7 +109,8 @@ var opts = {
password: 'password',
privatekey: 'the private key text',
passphrase: 'passphrase',
totp: 'totp'
totp: 'totp',
directory: '/var/www'
};
wssh.connect(opts);
@@ -169,6 +170,11 @@ Passing a command executed right after login
http://localhost:8888/?command=pwd
```
Passing an initial working directory (the shell runs `cd` right after login)
```bash
http://localhost:8888/?hostname=xx&username=yy&directory=/var/www
```
Passing a terminal type
```bash
http://localhost:8888/?term=xterm-256color
@@ -181,6 +187,11 @@ Start up the app
docker compose up -d
```
Rebuild after changing the code
```
docker compose up -d --build
```
Tear down the app
```
docker compose down
@@ -189,6 +200,27 @@ docker compose down
The bundled compose file persists login and saved connection data in the
`webssh-data` volume mounted at `/data` inside the container.
Extra options can be passed without rebuilding the image via `WEBSSH_OPTS`:
```
WEBSSH_OPTS="--maxconn=50 --tdstream=172.18.0.1" docker compose up -d
```
Set `--tdstream` to your reverse proxy address whenever `xheaders` is on
(the default). Without it any client can spoof `X-Forwarded-For` and claim
someone else's address, which defeats the per-client connection limit.
### Saved connections
Saved SSH passwords are encrypted at rest in `connections.json` with a key
derived from the server secret, and are never sent to the browser. Leaving
the password field empty for a saved host tells the server to use the one it
already holds; editing the hostname, port or username makes it a different
destination, so the stored credential is not reused there.
The server secret lives in `<data-dir>/cookie_secret` and can be overridden
with the `WEBSSH_COOKIE_SECRET` environment variable. Losing it does not break
the app, but saved passwords become unreadable and have to be entered again.
### Tests
Requirements
+3 -2
View File
@@ -67,8 +67,9 @@ You can also provide credentials with environment variables:
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
Saved connections are stored under the data directory too. WebSSH saves
hostname, port, username and terminal type for quick reconnects. It does
not persist ssh passwords, private keys, key passphrases or TOTP codes.
hostname, port, username, ssh password and terminal type for quick
reconnects. It does not persist private keys, key passphrases or TOTP
codes.
Use ``--auth=false`` only for a trusted private deployment where another
layer already protects access to WebSSH.
+11 -2
View File
@@ -1,10 +1,19 @@
version: '3'
services:
webssh:
build: .
build:
context: .
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-docker.io/python:3.12-slim}
restart: unless-stopped
ports:
- "8888:8888"
environment:
# extra wssh options, e.g. "--maxconn=50 --tdstream=172.18.0.1"
WEBSSH_OPTS: ${WEBSSH_OPTS:-}
# set this to keep saved passwords readable across container rebuilds
# when the data volume is recreated; otherwise a secret is generated
# in /data on first start
WEBSSH_COOKIE_SECRET: ${WEBSSH_COOKIE_SECRET:-}
volumes:
- webssh-data:/data
+7 -2
View File
@@ -20,6 +20,9 @@ print('Read key: ' + hexlify(host_key.get_fingerprint()).decode('utf-8'))
banner = u'\r\n\u6b22\u8fce\r\n'
event_timeout = 5
# clients that reuse a cached encoding never send the probe command, so this
# wait must be short and non-fatal
exec_timeout = 1
class Server(paramiko.ServerInterface):
@@ -41,6 +44,7 @@ class Server(paramiko.ServerInterface):
self.shell_event = threading.Event()
self.exec_event = threading.Event()
self.cmd_to_enc = self.get_cmd2enc(encodings)
self.encoding = 'UTF-8'
self.password_verified = False
self.key_verified = False
@@ -171,10 +175,11 @@ def run_ssh_server(port=2200, running=True, encodings=[]):
print('*** Client never asked for a shell.')
continue
server.exec_event.wait(timeout=event_timeout)
server.exec_event.wait(timeout=exec_timeout)
if not server.exec_event.is_set():
# the client already knew this server's encoding and skipped the
# probe; fall back to the default instead of dropping the session
print('*** Client never asked for a command.')
continue
# chan.send('\r\n\r\nWelcome!\r\n\r\n')
print(server.encoding)
+110
View File
@@ -1,5 +1,7 @@
import json
import random
import shutil
import tempfile
import threading
import tornado.websocket
import tornado.gen
@@ -29,6 +31,18 @@ server_encodings = {e.strip() for e in Server.encodings}
class TestAppBase(AsyncHTTPTestCase):
def setUp(self):
# keep auth.json/connections.json/cookie_secret out of the real
# data directory while testing
self.tmpdir = tempfile.mkdtemp()
options.data_dir = self.tmpdir
handler.encoding_cache.clear()
super(TestAppBase, self).setUp()
def tearDown(self):
super(TestAppBase, self).tearDown()
shutil.rmtree(self.tmpdir, ignore_errors=True)
def get_httpserver_options(self):
return get_server_settings(options)
@@ -327,6 +341,48 @@ class TestAppBasic(TestAppBase):
self.assertEqual(b'bye', msg)
ws.close()
def test_app_with_invalid_directory(self):
body = urlencode({
'hostname': '127.0.0.1',
'port': str(self.sshserver_port),
'_xsrf': 'yummy',
'username': 'robey',
'password': 'foo',
'directory': '/tmp\nreboot'
})
response = self.sync_post('/', body)
self.assert_response(b'Invalid directory', response)
@tornado.testing.gen_test
def test_app_with_directory_runs_cd_after_login(self):
body = urlencode({
'hostname': '127.0.0.1',
'port': str(self.sshserver_port),
'_xsrf': 'yummy',
'username': 'bar',
'password': 'foo',
'directory': "/var/o'brien"
})
url = self.get_url('/')
response = yield self.async_post(url, body)
data = json.loads(to_str(response.body))
self.assert_status_none(data)
url = url.replace('http', 'ws')
ws_url = url + 'ws?id=' + data['id']
ws = yield tornado.websocket.websocket_connect(ws_url)
# the mock server echoes back whatever the shell channel receives;
# the banner and the echo may or may not arrive in the same frame
received = b''
for _ in range(3):
received += (yield ws.read_message())
if b'cd ' in received:
break
self.assertIn(b"cd '/var/o'\\''brien'\r", received)
ws.close()
@tornado.testing.gen_test
def test_app_auth_with_valid_pubkey_by_urlencoded_form(self):
url = self.get_url('/')
@@ -792,3 +848,57 @@ class TestAppWithUnknownEncoding(OtherTestBase):
dic = json.loads(to_str(response.body))
self.assert_status_none(dic)
self.assertEqual(dic['encoding'], 'utf-8')
class TestAppWithSavedConnections(OtherTestBase):
def async_get(self, path):
return self.get_http_client().fetch(
self.get_url(path), headers=self.headers
)
@tornado.testing.gen_test
def test_saved_password_never_reaches_the_browser(self):
body = dict(self.body, save='1')
response = yield self.async_post('/', body)
data = json.loads(to_str(response.body))
self.assert_status_none(data)
profile = data['profile']
self.assertNotIn('password', profile)
self.assertNotIn('password_enc', profile)
self.assertTrue(profile['has_password'])
response = yield self.async_get('/connections')
self.assertNotIn(b'"foo"', response.body)
self.assertNotIn(b'password_enc', response.body)
listed = json.loads(to_str(response.body))['connections']
self.assertEqual(1, len(listed))
self.assertNotIn('password', listed[0])
self.assertTrue(listed[0]['has_password'])
@tornado.testing.gen_test
def test_saved_password_is_reused_when_the_field_is_left_empty(self):
body = dict(self.body, save='1')
response = yield self.async_post('/', body)
self.assert_status_none(json.loads(to_str(response.body)))
# no password in the form at all: the server supplies the stored one
body = dict(self.body, password='')
response = yield self.async_post('/', body)
self.assert_status_none(json.loads(to_str(response.body)))
@tornado.testing.gen_test
def test_saved_password_is_not_sent_to_another_destination(self):
body = dict(self.body, save='1')
response = yield self.async_post('/', body)
self.assert_status_none(json.loads(to_str(response.body)))
# a different username means a different profile id, so the stored
# credential must not be reused here
body = dict(self.body, username='bar', password='')
response = yield self.async_post('/', body)
self.assert_status_equal(
'Authentication failed.', json.loads(to_str(response.body))
)
+100
View File
@@ -5,6 +5,8 @@ import tempfile
from tornado.options import options
from tornado.testing import AsyncHTTPTestCase
from webssh.handler import login_rate_limiter
from webssh.worker import clients
from webssh.main import make_app, make_handlers
from webssh.settings import get_app_settings
from webssh.utils import to_str
@@ -62,3 +64,101 @@ class TestAppAuth(AsyncHTTPTestCase):
response = self.fetch('/connections', headers=headers)
data = json.loads(to_str(response.body))
self.assertEqual(data, {'connections': []})
def test_login_is_rate_limited_after_repeated_failures(self):
login_rate_limiter.clear()
self.addCleanup(login_rate_limiter.clear)
body = 'username=admin&password=secret&confirm=secret'
response = self.fetch('/login', method='POST', body=body,
follow_redirects=False)
self.assertEqual(response.code, 302)
wrong = 'username=admin&password=nope'
for _ in range(login_rate_limiter.max_attempts):
response = self.fetch('/login', method='POST', body=wrong)
self.assertIn('用户名或密码错误'.encode('utf-8'), response.body)
# further attempts are refused without touching the password hash
response = self.fetch('/login', method='POST', body=wrong)
self.assertIn('登录尝试过于频繁'.encode('utf-8'), response.body)
# ... and a correct password is refused too while the block lasts
good = 'username=admin&password=secret'
response = self.fetch('/login', method='POST', body=good,
follow_redirects=False)
self.assertEqual(response.code, 200)
self.assertIn('登录尝试过于频繁'.encode('utf-8'), response.body)
def test_successful_login_clears_the_failure_counter(self):
login_rate_limiter.clear()
self.addCleanup(login_rate_limiter.clear)
body = 'username=admin&password=secret&confirm=secret'
self.fetch('/login', method='POST', body=body, follow_redirects=False)
self.fetch('/login', method='POST', body='username=admin&password=x')
self.assertNotEqual({}, login_rate_limiter.attempts)
response = self.fetch('/login', method='POST',
body='username=admin&password=secret',
follow_redirects=False)
self.assertEqual(response.code, 302)
self.assertEqual({}, login_rate_limiter.attempts)
class TestMaxConnPerUser(AsyncHTTPTestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
clients.clear()
login_rate_limiter.clear()
self.addCleanup(clients.clear)
self.addCleanup(login_rate_limiter.clear)
super(TestMaxConnPerUser, self).setUp()
def tearDown(self):
super(TestMaxConnPerUser, self).tearDown()
shutil.rmtree(self.tmpdir, ignore_errors=True)
def get_app(self):
loop = self.io_loop
options.auth = True
options.auth_username = 'admin'
options.auth_password = 'secret'
options.auth_password_hash = ''
options.auth_session_days = 7
options.data_dir = self.tmpdir
options.debug = False
options.xsrf = False
options.policy = 'warning'
options.hostfile = ''
options.syshostfile = ''
options.tdstream = ''
options.origin = 'same'
options.maxconn = 1
return make_app(make_handlers(loop, options), get_app_settings(options))
def login(self):
response = self.fetch('/login', method='POST',
body='username=admin&password=secret',
follow_redirects=False)
cookie = response.headers.get_list('Set-Cookie')[0]
return {'Cookie': cookie.split(';', 1)[0]}
def test_limit_follows_the_login_not_the_address(self):
headers = self.login()
body = 'hostname=127.0.0.1&port=65000&username=robey'
# one live session for this login already exhausts maxconn=1
clients['user:admin'] = {'fake': None}
response = self.fetch('/', method='POST', body=body, headers=headers)
self.assertIn(b'Too many live connections', response.body)
# another user shares the same source address but has its own budget,
# so it gets as far as actually dialing the (absent) ssh server
clients.clear()
clients['user:someone-else'] = {'fake': None}
response = self.fetch('/', method='POST', body=body, headers=headers)
self.assertNotIn(b'Too many live connections', response.body)
self.assertIn(b'Unable to connect to', response.body)
+270 -5
View File
@@ -8,7 +8,7 @@ from tests.utils import read_file, make_tests_data_path
from webssh import handler
from webssh import worker
from webssh.handler import (
IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient
IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient, LoginRateLimiter, EncodingCache
)
try:
@@ -318,16 +318,103 @@ class TestWsockHandler(unittest.TestCase):
obj.close.assert_called_with(reason='Worker closed')
class TestIndexHandler(unittest.TestCase):
def test_get_args_keeps_password_for_saved_connection(self):
obj = Mock(spec=IndexHandler)
obj.get_hostname.return_value = '127.0.0.1'
obj.get_port.return_value = 22
obj.get_value.return_value = 'root'
obj.get_privatekey.return_value = ('', '')
obj.get_directory.return_value = ''
obj.policy = paramiko.WarningPolicy()
obj.ssh_client = Mock()
values = {
'password': 'root-secret',
'passphrase': '',
'totp': '',
'term': 'xterm-256color'
}
obj.get_argument.side_effect = lambda name, default=u'': values.get(
name, default
)
args = IndexHandler.get_args(obj)
self.assertEqual(
('127.0.0.1', 22, 'root', 'root-secret'),
args[:4]
)
self.assertEqual('root-secret', obj.connection_info['password'])
def test_get_args_carries_directory(self):
obj = Mock(spec=IndexHandler)
obj.get_hostname.return_value = '127.0.0.1'
obj.get_port.return_value = 22
obj.get_value.return_value = 'root'
obj.get_privatekey.return_value = ('', '')
obj.get_directory.return_value = '/var/www'
obj.policy = paramiko.WarningPolicy()
obj.ssh_client = Mock()
obj.get_argument.side_effect = lambda name, default=u'': default
args = IndexHandler.get_args(obj)
self.assertEqual('/var/www', args[6])
self.assertEqual('/var/www', obj.connection_info['directory'])
def test_get_directory(self):
obj = Mock(spec=IndexHandler)
values = {}
obj.get_argument.side_effect = lambda name, default=u'': values.get(
name, default
)
self.assertEqual('', IndexHandler.get_directory(obj))
values['directory'] = ' '
self.assertEqual('', IndexHandler.get_directory(obj))
values['directory'] = ' /var/www '
self.assertEqual('/var/www', IndexHandler.get_directory(obj))
values['directory'] = '/tmp\nrm -rf /'
with self.assertRaises(InvalidValueError):
IndexHandler.get_directory(obj)
values['directory'] = '/tmp' + 'a' * 1024
with self.assertRaises(InvalidValueError):
IndexHandler.get_directory(obj)
def test_change_directory_quotes_the_path(self):
obj = Mock(spec=IndexHandler)
chan = Mock()
IndexHandler.change_directory(obj, chan, '/tmp; reboot')
chan.sendall.assert_called_with(b"cd '/tmp; reboot'\r")
def test_change_directory_swallows_channel_errors(self):
obj = Mock(spec=IndexHandler)
chan = Mock()
chan.sendall.side_effect = paramiko.SSHException('boom')
# a failed cd must not prevent the session from starting
IndexHandler.change_directory(obj, chan, '/var/www')
def test_null_in_encoding(self):
handler = Mock(spec=IndexHandler)
mock_handler = Mock(spec=IndexHandler)
# This is a little nasty, but the index handler has a lot of
# dependencies to mock. Mocking out everything but the bits
# we want to test lets us test this case without needing to
# refactor the relevant code out of IndexHandler
def parse_encoding(data):
return IndexHandler.parse_encoding(handler, data)
handler.parse_encoding = parse_encoding
return IndexHandler.parse_encoding(mock_handler, data)
mock_handler.parse_encoding = parse_encoding
def detect_encoding(ssh):
return IndexHandler.detect_encoding(mock_handler, ssh)
mock_handler.detect_encoding = detect_encoding
ssh = Mock(spec=SSHClient)
stdin = io.BytesIO()
@@ -335,6 +422,184 @@ class TestIndexHandler(unittest.TestCase):
stderr = io.BytesIO()
ssh.exec_command.return_value = (stdin, stdout, stderr)
encoding = IndexHandler.get_default_encoding(handler, ssh)
self.assertIsNone(IndexHandler.detect_encoding(mock_handler, ssh))
encoding = IndexHandler.get_default_encoding(mock_handler, ssh)
self.assertEqual("utf-8", encoding)
class TestLoginRateLimiter(unittest.TestCase):
def test_blocks_after_too_many_failures(self):
limiter = LoginRateLimiter(max_attempts=3, window=300)
self.assertFalse(limiter.is_blocked('1.1.1.1'))
for _ in range(2):
limiter.record_failure('1.1.1.1')
self.assertFalse(limiter.is_blocked('1.1.1.1'))
limiter.record_failure('1.1.1.1')
self.assertTrue(limiter.is_blocked('1.1.1.1'))
# other clients are unaffected
self.assertFalse(limiter.is_blocked('2.2.2.2'))
def test_success_resets_the_counter(self):
limiter = LoginRateLimiter(max_attempts=2, window=300)
limiter.record_failure('1.1.1.1')
limiter.record_failure('1.1.1.1')
self.assertTrue(limiter.is_blocked('1.1.1.1'))
limiter.reset('1.1.1.1')
self.assertFalse(limiter.is_blocked('1.1.1.1'))
def test_entries_expire_after_the_window(self):
limiter = LoginRateLimiter(max_attempts=1, window=300)
limiter.record_failure('1.1.1.1')
self.assertTrue(limiter.is_blocked('1.1.1.1'))
# pretend the failure happened longer ago than the window
count, last = limiter.attempts['1.1.1.1']
limiter.attempts['1.1.1.1'] = (count, last - 301)
self.assertFalse(limiter.is_blocked('1.1.1.1'))
self.assertEqual({}, limiter.attempts)
def test_clear(self):
limiter = LoginRateLimiter(max_attempts=1, window=300)
limiter.record_failure('1.1.1.1')
self.assertTrue(limiter.is_blocked('1.1.1.1'))
limiter.clear()
self.assertFalse(limiter.is_blocked('1.1.1.1'))
class TestEncodingCache(unittest.TestCase):
def test_get_and_set(self):
cache = EncodingCache()
self.assertIsNone(cache.get(('127.0.0.1', 22)))
cache.set(('127.0.0.1', 22), 'GBK')
self.assertEqual('GBK', cache.get(('127.0.0.1', 22)))
# the port is part of the key
self.assertIsNone(cache.get(('127.0.0.1', 2200)))
def test_a_failed_probe_is_not_cached(self):
cache = EncodingCache()
cache.set(('127.0.0.1', 22), None)
cache.set(('127.0.0.1', 22), '')
self.assertIsNone(cache.get(('127.0.0.1', 22)))
def test_cache_is_bounded(self):
cache = EncodingCache(maxsize=2)
cache.set(('a', 22), 'UTF-8')
cache.set(('b', 22), 'UTF-8')
cache.set(('c', 22), 'UTF-8')
self.assertEqual(1, len(cache.cache))
self.assertEqual('UTF-8', cache.get(('c', 22)))
class TestGetEncoding(unittest.TestCase):
def setUp(self):
handler.encoding_cache.clear()
self.addCleanup(handler.encoding_cache.clear)
def make_handler(self, detected):
obj = Mock(spec=IndexHandler)
obj.detect_encoding.return_value = detected
return obj
def test_probes_once_then_serves_from_cache(self):
obj = self.make_handler('GBK')
ssh = Mock()
dst_addr = ('127.0.0.1', 22)
self.assertEqual('GBK', IndexHandler.get_encoding(obj, ssh, dst_addr))
self.assertEqual('GBK', IndexHandler.get_encoding(obj, ssh, dst_addr))
self.assertEqual(1, obj.detect_encoding.call_count)
def test_failed_detection_falls_back_without_caching(self):
obj = self.make_handler(None)
ssh = Mock()
dst_addr = ('127.0.0.1', 22)
self.assertEqual(
'utf-8', IndexHandler.get_encoding(obj, ssh, dst_addr)
)
self.assertEqual(
'utf-8', IndexHandler.get_encoding(obj, ssh, dst_addr)
)
self.assertEqual(2, obj.detect_encoding.call_count)
class TestClientKey(unittest.TestCase):
def make_handler(self, auth_manager, addr=('10.0.0.1', 1234)):
obj = Mock(spec=MixinHandler)
obj.auth_manager = auth_manager
obj.get_client_addr.return_value = addr
return obj
def test_logged_in_users_get_their_own_bucket(self):
alice = self.make_handler(Mock())
alice.current_user = 'alice'
bob = self.make_handler(Mock())
bob.current_user = 'bob'
# same source address, different buckets
self.assertEqual('user:alice', MixinHandler.get_client_key(alice))
self.assertEqual('user:bob', MixinHandler.get_client_key(bob))
def test_without_auth_the_address_is_used(self):
obj = self.make_handler(None)
self.assertEqual('10.0.0.1', MixinHandler.get_client_key(obj))
def test_a_username_cannot_collide_with_an_address(self):
spoofer = self.make_handler(Mock())
spoofer.current_user = '10.0.0.1'
direct = self.make_handler(None)
self.assertNotEqual(
MixinHandler.get_client_key(spoofer),
MixinHandler.get_client_key(direct)
)
class TestClearWorker(unittest.TestCase):
def make_worker(self, worker_id, client_key):
obj = Mock()
obj.id = worker_id
obj.client_key = client_key
return obj
def test_removes_the_worker_and_prunes_empty_buckets(self):
a = self.make_worker('a', 'user:admin')
b = self.make_worker('b', 'user:admin')
clients = {'user:admin': {'a': a, 'b': b}}
worker.clear_worker(a, clients)
self.assertEqual({'user:admin': {'b': b}}, clients)
worker.clear_worker(b, clients)
self.assertEqual({}, clients)
def test_unregistered_worker_is_ignored(self):
orphan = self.make_worker('a', None)
clients = {}
# must not raise even though the worker was never registered
worker.clear_worker(orphan, clients)
self.assertEqual({}, clients)
clients = {'user:admin': {'b': 'other'}}
orphan.client_key = 'user:admin'
worker.clear_worker(orphan, clients)
self.assertEqual({'user:admin': {'b': 'other'}}, clients)
+151 -2
View File
@@ -3,29 +3,40 @@ import shutil
import tempfile
import unittest
from webssh.crypto import is_encrypted
from webssh.storage import ConnectionStore
SECRET = 'test-server-secret'
class TestConnectionStore(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.store = ConnectionStore(os.path.join(self.tmpdir,
'connections.json'))
self.filename = os.path.join(self.tmpdir, 'connections.json')
self.store = ConnectionStore(self.filename, SECRET)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def read_raw(self):
with open(self.filename, encoding='utf-8') as f:
return f.read()
def test_upsert_list_and_delete(self):
profile = self.store.upsert('admin', {
'hostname': '127.0.0.1',
'port': 22,
'username': 'root',
'password': 'root-secret',
'term': 'xterm-256color',
'directory': '/var/www',
'auth_type': 'password'
})
self.assertEqual(profile['title'], 'root@127.0.0.1:22')
self.assertEqual(profile['directory'], '/var/www')
self.assertEqual([profile], self.store.list('admin'))
self.assertEqual([], self.store.list('other'))
@@ -33,6 +44,7 @@ class TestConnectionStore(unittest.TestCase):
'hostname': '127.0.0.1',
'port': 22,
'username': 'root',
'password': 'new-secret',
'term': 'xterm',
'auth_type': 'privatekey'
})
@@ -40,7 +52,144 @@ class TestConnectionStore(unittest.TestCase):
self.assertEqual(profile['id'], updated['id'])
self.assertEqual(1, len(self.store.list('admin')))
self.assertEqual('xterm', self.store.list('admin')[0]['term'])
self.assertEqual('', self.store.list('admin')[0]['directory'])
self.assertTrue(self.store.delete('admin', profile['id']))
self.assertFalse(self.store.delete('admin', profile['id']))
self.assertEqual([], self.store.list('admin'))
def test_password_never_leaves_the_store(self):
profile = self.store.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root',
'password': 'root-secret'
})
self.assertNotIn('password', profile)
self.assertNotIn('password_enc', profile)
self.assertTrue(profile['has_password'])
listed = self.store.list('admin')[0]
self.assertNotIn('password', listed)
self.assertNotIn('password_enc', listed)
self.assertTrue(listed['has_password'])
# ... but it is still usable server side
self.assertEqual(
'root-secret', self.store.get_password('admin', profile['id'])
)
def test_password_is_encrypted_at_rest(self):
self.store.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root',
'password': 'root-secret'
})
raw = self.read_raw()
self.assertNotIn('root-secret', raw)
self.assertIn('password_enc', raw)
def test_password_is_scoped_to_owner_and_destination(self):
profile = self.store.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root',
'password': 'root-secret'
})
self.assertEqual(
'', self.store.get_password('mallory', profile['id'])
)
self.assertEqual('', self.store.get_password('admin', 'deadbeef'))
# an edited hostname yields a different id, so nothing is found
other_id = self.store.make_id('admin', 'evil.example.com', 22, 'root')
self.assertNotEqual(profile['id'], other_id)
self.assertEqual('', self.store.get_password('admin', other_id))
def test_profile_without_password(self):
profile = self.store.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root'
})
self.assertFalse(profile['has_password'])
self.assertEqual('', self.store.get_password('admin', profile['id']))
def test_a_wrong_secret_does_not_break_the_store(self):
profile = self.store.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root',
'password': 'root-secret'
})
rotated = ConnectionStore(self.filename, 'a-different-secret')
self.assertEqual('', rotated.get_password('admin', profile['id']))
self.assertTrue(rotated.list('admin')[0]['has_password'])
def test_encrypt_plaintext_passwords_migrates_old_files(self):
legacy = ConnectionStore(self.filename)
profile = legacy.upsert('admin', {
'hostname': '127.0.0.1',
'username': 'root',
'password': 'legacy-secret'
})
self.assertIn('legacy-secret', self.read_raw())
self.assertEqual(
'legacy-secret', legacy.get_password('admin', profile['id'])
)
self.assertEqual(1, self.store.encrypt_plaintext_passwords())
raw = self.read_raw()
self.assertNotIn('legacy-secret', raw)
self.assertEqual(
'legacy-secret', self.store.get_password('admin', profile['id'])
)
self.assertTrue(self.store.list('admin')[0]['has_password'])
# already migrated, nothing left to do
self.assertEqual(0, self.store.encrypt_plaintext_passwords())
def test_directory_is_normalized_and_optional(self):
profile = self.store.upsert('admin', {
'hostname': 'example.com',
'username': 'deploy',
'directory': ' /srv/app '
})
self.assertEqual('/srv/app', profile['directory'])
profile = self.store.upsert('admin', {
'hostname': 'example.org',
'username': 'deploy'
})
self.assertEqual('', profile['directory'])
class TestSecretBox(unittest.TestCase):
def test_round_trip(self):
from webssh.crypto import SecretBox
box = SecretBox(SECRET)
token = box.encrypt('hunter2')
self.assertTrue(is_encrypted(token))
self.assertNotIn('hunter2', token)
self.assertEqual('hunter2', box.decrypt(token))
def test_nonce_is_not_reused(self):
from webssh.crypto import SecretBox
box = SecretBox(SECRET)
self.assertNotEqual(box.encrypt('same'), box.encrypt('same'))
def test_empty_and_tampered_values(self):
from webssh.crypto import SecretBox
box = SecretBox(SECRET)
self.assertEqual('', box.encrypt(''))
self.assertEqual('', box.decrypt(''))
self.assertEqual('', box.decrypt('not-a-token'))
self.assertEqual('', box.decrypt('aesgcm$####'))
token = box.encrypt('hunter2')
self.assertEqual('', box.decrypt(token[:-4] + 'AAAA'))
self.assertEqual('', SecretBox('other').decrypt(token))
+38 -1
View File
@@ -2,7 +2,8 @@ import unittest
from webssh.utils import (
is_valid_ip_address, is_valid_port, is_valid_hostname, to_str, to_bytes,
to_int, is_ip_hostname, is_same_primary_domain, parse_origin_from_url
to_int, is_ip_hostname, is_same_primary_domain, parse_origin_from_url,
is_valid_directory, quote_shell_arg, build_cd_command
)
@@ -56,6 +57,42 @@ class TestUitls(unittest.TestCase):
self.assertFalse(is_valid_hostname('127.0.0.1'))
self.assertFalse(is_valid_hostname('::1'))
def test_is_valid_directory(self):
self.assertTrue(is_valid_directory('/var/www'))
self.assertTrue(is_valid_directory('~/projects'))
self.assertTrue(is_valid_directory('/tmp/a b'))
self.assertTrue(is_valid_directory('/srv/项目'))
self.assertTrue(is_valid_directory("/tmp/o'brien"))
self.assertFalse(is_valid_directory(''))
self.assertFalse(is_valid_directory(None))
self.assertFalse(is_valid_directory('/tmp\nrm -rf /'))
self.assertFalse(is_valid_directory('/tmp\rwhoami'))
self.assertFalse(is_valid_directory('/tmp\x00'))
self.assertFalse(is_valid_directory('/tmp\x1b[31m'))
self.assertTrue(is_valid_directory('/' + 'a' * 1023))
self.assertFalse(is_valid_directory('/' + 'a' * 1024))
def test_quote_shell_arg(self):
self.assertEqual(quote_shell_arg('/var/www'), "'/var/www'")
self.assertEqual(quote_shell_arg('/tmp/a b'), "'/tmp/a b'")
self.assertEqual(
quote_shell_arg('/tmp; rm -rf /'), "'/tmp; rm -rf /'"
)
self.assertEqual(
quote_shell_arg('/tmp/$(whoami)'), "'/tmp/$(whoami)'"
)
# a single quote is closed, escaped, then reopened
self.assertEqual(quote_shell_arg("o'brien"), "'o'\\''brien'")
self.assertEqual(
quote_shell_arg("'; rm -rf /; '"), "''\\''; rm -rf /; '\\'''"
)
def test_build_cd_command(self):
self.assertEqual(build_cd_command('/var/www'), "cd '/var/www'\r")
self.assertEqual(
build_cd_command('/tmp; reboot'), "cd '/tmp; reboot'\r"
)
def test_is_ip_hostname(self):
self.assertTrue(is_ip_hostname('[::1]'))
self.assertTrue(is_ip_hostname('127.0.0.1'))
+60
View File
@@ -0,0 +1,60 @@
import base64
import logging
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from webssh.utils import to_bytes, to_str
NONCE_SIZE = 12
PREFIX = 'aesgcm$'
DEFAULT_INFO = b'webssh connection password'
class SecretBox(object):
"""Authenticated encryption for secrets kept in the data directory.
The key is derived from the server secret, so a stolen connections.json
is useless without the secret file next to it.
"""
def __init__(self, secret, info=DEFAULT_INFO):
self.key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=info
).derive(to_bytes(secret))
def encrypt(self, plaintext):
if not plaintext:
return ''
nonce = os.urandom(NONCE_SIZE)
blob = nonce + AESGCM(self.key).encrypt(
nonce, to_bytes(plaintext), None
)
return PREFIX + base64.b64encode(blob).decode('ascii')
def decrypt(self, token):
if not is_encrypted(token):
return ''
try:
blob = base64.b64decode(token[len(PREFIX):])
return to_str(
AESGCM(self.key).decrypt(
blob[:NONCE_SIZE], blob[NONCE_SIZE:], None
)
)
except Exception:
# a rotated or lost secret must not take the whole app down
logging.warning('Could not decrypt a stored secret.')
return ''
def is_encrypted(token):
return bool(token) and isinstance(token, str) and token.startswith(PREFIX)
+181 -10
View File
@@ -3,6 +3,8 @@ import json
import logging
import socket
import struct
import threading
import time
import traceback
import weakref
import paramiko
@@ -15,7 +17,7 @@ from tornado.process import cpu_count
from webssh.utils import (
is_valid_ip_address, is_valid_port, is_valid_hostname, to_bytes, to_str,
to_int, to_ip_address, UnicodeType, is_ip_hostname, is_same_primary_domain,
is_valid_encoding
is_valid_encoding, is_valid_directory, build_cd_command
)
from webssh.worker import Worker, recycle_worker, clients
@@ -36,6 +38,81 @@ swallow_http_errors = True
redirecting = None
class LoginRateLimiter(object):
"""Throttle repeated failed logins from the same client address."""
def __init__(self, max_attempts=8, window=300):
self.max_attempts = max_attempts
self.window = window
self.lock = threading.Lock()
self.attempts = {}
def _prune(self, now):
expired = [
key for key, (_, last) in self.attempts.items()
if now - last > self.window
]
for key in expired:
self.attempts.pop(key, None)
def is_blocked(self, key):
now = time.monotonic()
with self.lock:
self._prune(now)
count, last = self.attempts.get(key, (0, 0))
return count >= self.max_attempts and now - last <= self.window
def record_failure(self, key):
now = time.monotonic()
with self.lock:
self._prune(now)
count, _ = self.attempts.get(key, (0, 0))
self.attempts[key] = (count + 1, now)
def reset(self, key):
with self.lock:
self.attempts.pop(key, None)
def clear(self):
with self.lock:
self.attempts.clear()
login_rate_limiter = LoginRateLimiter()
class EncodingCache(object):
"""Remember each server's encoding so we probe it only once.
Detection costs up to two `locale charmap` round trips with a one second
timeout each, which is otherwise paid on every single connection.
"""
def __init__(self, maxsize=1024):
self.maxsize = maxsize
self.lock = threading.Lock()
self.cache = {}
def get(self, key):
with self.lock:
return self.cache.get(key)
def set(self, key, encoding):
if not encoding:
return
with self.lock:
if len(self.cache) >= self.maxsize:
self.cache.clear()
self.cache[key] = encoding
def clear(self):
with self.lock:
self.cache.clear()
encoding_cache = EncodingCache()
class InvalidValueError(Exception):
pass
@@ -312,6 +389,17 @@ class MixinHandler(object):
else:
return self.get_context_addr()
def get_client_key(self):
"""Bucket live sessions by login when there is one, by address if not.
Behind a reverse proxy or a Docker bridge many browsers can share one
source address, which would turn the per-client maxconn limit into a
server-wide one.
"""
if self.auth_manager:
return 'user:{}'.format(self.current_user)
return self.get_client_addr()[0]
def get_real_client_addr(self):
ip = self.request.remote_ip
@@ -333,6 +421,9 @@ class MixinHandler(object):
class LoginHandler(MixinHandler, tornado.web.RequestHandler):
# PBKDF2 takes ~100ms; running it inline would stall every live terminal
executor = ThreadPoolExecutor(max_workers=2)
def initialize(self):
super(LoginHandler, self).initialize()
@@ -348,6 +439,7 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler):
self.render_login()
@tornado.gen.coroutine
def post(self):
manager = self.auth_manager
if not manager:
@@ -358,10 +450,23 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler):
password = self.get_argument('password', u'')
if manager.is_configured():
if manager.verify(username, password):
client = self.get_client_addr()[0]
if login_rate_limiter.is_blocked(client):
logging.warning(
'Too many failed logins from {}'.format(client)
)
self.render_login('登录尝试过于频繁,请稍后再试。')
return
verified = yield self.executor.submit(
manager.verify, username, password
)
if verified:
login_rate_limiter.reset(client)
self.set_login_cookie(username)
self.redirect(self.get_safe_next_url())
else:
login_rate_limiter.record_failure(client)
self.render_login('用户名或密码错误。')
return
@@ -377,7 +482,7 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler):
return
try:
manager.setup(username, password)
yield self.executor.submit(manager.setup, username, password)
except ValueError as exc:
self.render_login(str(exc))
else:
@@ -527,6 +632,30 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
hostname, port)
)
def get_directory(self):
value = self.get_argument('directory', u'').strip()
if not value:
return u''
if not is_valid_directory(value):
raise InvalidValueError('Invalid directory: {}'.format(value))
return value
def get_saved_password(self, hostname, port, username):
"""Look up a stored password without ever sending it to the client.
The profile id is derived from the destination, so an edited
hostname/port/username simply finds nothing instead of forwarding
the saved credential somewhere it does not belong.
"""
store = self.connection_store
if not store:
return u''
profile_id = store.make_id(
self.current_user, hostname, port, username
)
return store.get_password(self.current_user, profile_id)
def get_args(self):
hostname = self.get_hostname()
port = self.get_port()
@@ -536,6 +665,10 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
passphrase = self.get_argument('passphrase', u'')
totp = self.get_argument('totp', u'')
term = self.get_argument('term', u'') or u'xterm'
directory = self.get_directory()
if not password and not privatekey:
password = self.get_saved_password(hostname, port, username)
if isinstance(self.policy, paramiko.RejectPolicy):
self.lookup_hostname(hostname, port)
@@ -546,12 +679,14 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
pkey = None
self.ssh_client.totp = totp
args = (hostname, port, username, password, pkey, term)
args = (hostname, port, username, password, pkey, term, directory)
self.connection_info = {
'hostname': hostname,
'port': port,
'username': username,
'password': password,
'term': term,
'directory': directory,
'auth_type': 'privatekey' if privatekey else 'password'
}
logging.debug(args[:5])
@@ -568,6 +703,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
return encoding
def get_default_encoding(self, ssh):
return self.detect_encoding(ssh) or 'utf-8'
def detect_encoding(self, ssh):
commands = [
'$SHELL -ilc "locale charmap"',
'$SHELL -ic "locale charmap"'
@@ -592,13 +730,13 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
return result
logging.warning('Could not detect the default encoding.')
return 'utf-8'
def ssh_connect(self, args):
ssh = self.ssh_client
dst_addr = args[:2]
connect_args = args[:5]
term = args[5]
directory = args[6]
logging.info('Connecting to {}:{}'.format(*dst_addr))
try:
@@ -613,12 +751,43 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
raise ValueError('Bad host key.')
chan = ssh.invoke_shell(term=term)
if directory:
self.change_directory(chan, directory)
chan.setblocking(0)
worker = Worker(self.loop, ssh, chan, dst_addr)
worker.encoding = options.encoding if options.encoding else \
self.get_default_encoding(ssh)
self.get_encoding(ssh, args[:3])
return worker
def get_encoding(self, ssh, cache_key):
"""Detect the server encoding once per (host, port, user)."""
cached = encoding_cache.get(cache_key)
if cached:
logging.debug(
'Using cached encoding {!r} for {!r}'.format(cached, cache_key)
)
return cached
# a failed probe is not cached, so a transient hiccup does not pin
# the server to the fallback encoding until the next restart
encoding = self.detect_encoding(ssh)
encoding_cache.set(cache_key, encoding)
return encoding or 'utf-8'
def change_directory(self, chan, directory):
"""Queue a cd command on the pty so the shell runs it at startup."""
try:
chan.settimeout(options.timeout)
chan.sendall(to_bytes(build_cd_command(directory)))
except (OSError, IOError, paramiko.SSHException) as exc:
logging.warning(
'Could not change directory to {!r}: {}'.format(
directory, exc
)
)
finally:
chan.settimeout(None)
def check_origin(self):
event_origin = self.get_argument('_origin', u'')
header_origin = self.request.headers.get('Origin')
@@ -655,8 +824,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
raise ValueError('Uncaught exception')
ip, port = self.get_client_addr()
workers = clients.get(ip, {})
if workers and len(workers) >= options.maxconn:
client_key = self.get_client_key()
workers = clients.get(client_key, {})
if len(workers) >= options.maxconn:
raise tornado.web.HTTPError(403, 'Too many live connections.')
self.check_origin()
@@ -675,8 +845,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
self.result.update(status=str(exc))
else:
if not workers:
clients[ip] = workers
clients[client_key] = workers
worker.src_addr = (ip, port)
worker.client_key = client_key
workers[worker.id] = worker
self.loop.call_later(options.delay, recycle_worker, worker)
self.result.update(id=worker.id, encoding=worker.encoding)
@@ -712,7 +883,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
self.src_addr = self.get_client_addr()
logging.info('Connected from {}:{}'.format(*self.src_addr))
workers = clients.get(self.src_addr[0])
workers = clients.get(self.get_client_key())
if not workers:
self.close(reason='Websocket authentication failed.')
return
+16
View File
@@ -47,6 +47,21 @@ def app_listen(app, port, address, server_settings):
)
def check_trusted_downstream(options, server_settings):
if not options.xheaders:
return
if server_settings.get('trusted_downstream'):
return
logging.warning(
'xheaders is enabled without --tdstream, so any client can spoof '
'X-Forwarded-For and impersonate another address. Set '
'--tdstream=<proxy ip> when running behind a reverse proxy, or '
'--xheaders=False when clients connect directly.'
)
def main():
options.parse_command_line()
check_encoding_setting(options.encoding)
@@ -54,6 +69,7 @@ def main():
app = make_app(make_handlers(loop, options), get_app_settings(options))
ssl_ctx = get_ssl_context(options)
server_settings = get_server_settings(options)
check_trusted_downstream(options, server_settings)
app_listen(app, options.port, options.address, server_settings)
if ssl_ctx:
server_settings.update(ssl_options=ssl_ctx)
+16 -5
View File
@@ -45,7 +45,7 @@ define('origin', default='same', help='''Origin policy,
'<domains>': custom domains policy, matches any domain in the <domains> list
separated by comma;
'*': wildcard policy, matches any domain, allowed in debug mode only.''')
define('wpintvl', type=float, default=0, help='Websocket ping interval')
define('wpintvl', type=float, default=30, help='Websocket ping interval')
define('timeout', type=float, default=3, help='SSH connection timeout')
define('delay', type=float, default=3, help='The delay to call recycle_worker')
define('maxconn', type=int, default=20,
@@ -86,6 +86,8 @@ class Font(object):
def __init__(self, filename, dirs):
self.family = self.get_family(filename)
self.url = self.get_url(filename, dirs)
# path relative to static_path, for static_url() in templates
self.path = self.get_url(filename, dirs[1:])
def get_family(self, filename):
return filename.split('.')[0]
@@ -96,6 +98,7 @@ class Font(object):
def get_app_settings(options):
auth_manager = get_auth_manager(options)
secret = get_cookie_secret(options)
settings = dict(
template_path=os.path.join(base_dir, 'webssh', 'templates'),
static_path=os.path.join(base_dir, 'webssh', 'static'),
@@ -105,7 +108,7 @@ def get_app_settings(options):
login_url='/login',
auth_manager=auth_manager,
auth_session_days=options.auth_session_days,
connection_store=get_connection_store(options),
connection_store=get_connection_store(options, secret),
font=Font(
get_font_filename(options.font,
os.path.join(base_dir, *font_dirs)),
@@ -114,7 +117,7 @@ def get_app_settings(options):
origin_policy=get_origin_setting(options)
)
if auth_manager:
settings['cookie_secret'] = get_cookie_secret(options)
settings['cookie_secret'] = secret
return settings
@@ -241,11 +244,19 @@ def get_auth_manager(options):
)
def get_connection_store(options):
def get_connection_store(options, secret=''):
filename = os.path.join(
os.path.expanduser(options.data_dir), 'connections.json'
)
return ConnectionStore(filename)
store = ConnectionStore(filename, secret)
encrypted = store.encrypt_plaintext_passwords()
if encrypted:
logging.info(
'Encrypted {} plaintext password(s) in {}'.format(
encrypted, filename
)
)
return store
def get_cookie_secret(options):
+128
View File
@@ -152,6 +152,43 @@ a:hover {
font-weight: 650;
}
.field-heading {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.field-heading label {
margin-bottom: 0;
}
.field-hint {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.inline-toggle {
display: inline-flex !important;
gap: 6px;
align-items: center;
color: var(--muted) !important;
font-size: 12px !important;
font-weight: 600 !important;
white-space: nowrap;
cursor: pointer;
}
.inline-toggle input {
width: 15px;
height: 15px;
accent-color: var(--brand);
}
.form-control {
min-height: 44px;
color: var(--text);
@@ -281,6 +318,97 @@ input[type="file"].form-control {
min-height: 0;
}
#terminal.terminal-fullscreen {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 255;
overflow: hidden;
background: #0f1720;
}
#terminal.terminal-fullscreen .xterm {
width: 100%;
height: 100%;
}
body.quickbar-open #terminal.terminal-fullscreen {
bottom: 46px;
bottom: calc(46px + env(safe-area-inset-bottom));
}
.quickbar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 300;
display: flex;
gap: 6px;
align-items: center;
padding: 6px 10px;
padding-bottom: calc(6px + env(safe-area-inset-bottom));
overflow-x: auto;
background: #101a23;
border-top: 1px solid rgba(255, 255, 255, 0.08);
-webkit-overflow-scrolling: touch;
}
.quickbar-btn {
flex: 0 0 auto;
min-width: 42px;
min-height: 34px;
padding: 4px 12px;
color: #d9f1ee;
font-size: 13px;
font-family: SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
background: #1d2b36;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
touch-action: manipulation;
}
.quickbar-btn:hover {
background: #273946;
}
.quickbar-btn:active {
color: #0f1720;
background: #55c7ba;
border-color: #55c7ba;
}
.quickbar-sep {
flex: 0 0 auto;
align-self: center;
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.16);
}
.terminal-toast {
position: fixed;
top: 14px;
left: 50%;
z-index: 310;
padding: 7px 16px;
color: #eaf7f5;
font-size: 13px;
background: rgba(16, 26, 35, 0.92);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity 0.2s ease;
}
.terminal-toast.visible {
opacity: 1;
}
#waiter {
position: fixed;
inset: 0;
+344 -19
View File
@@ -43,7 +43,11 @@ jQuery(function($){
saved_connections_select = $('#saved-connections'),
load_connection_button = $('#load-connection'),
delete_connection_button = $('#delete-connection'),
show_password_checkbox = $('#show-password'),
term_type = $('#term'),
quickbar = $('#quickbar'),
terminal_toast = $('#terminal-toast'),
toast_timer,
style = {},
default_title = 'WebSSH',
title_element = document.querySelector('title'),
@@ -51,14 +55,16 @@ jQuery(function($){
debug = document.querySelector(form_id).noValidate,
custom_font = document.fonts ? document.fonts.values().next().value : undefined,
default_fonts,
current_terminal,
DISCONNECTED = 0,
CONNECTING = 1,
CONNECTED = 2,
state = DISCONNECTED,
messages = {1: '正在连接,请稍候 ...', 2: '当前客户端已经连接。'},
key_max_size = 16384,
directory_max_size = 1024,
fields = ['hostname', 'port', 'username'],
form_keys = fields.concat(['password', 'totp']),
form_keys = fields.concat(['password', 'totp', 'directory']),
opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'],
url_form_data = {},
url_opts_data = {},
@@ -180,24 +186,47 @@ jQuery(function($){
}
function update_password_visibility() {
$('#password').attr(
'type',
show_password_checkbox.prop('checked') ? 'text' : 'password'
);
}
function apply_saved_connection() {
var profile = selected_connection();
if (!profile) {
apply_saved_password_hint(false);
return;
}
$('#hostname').val(profile.hostname);
$('#port').val(profile.port);
$('#username').val(profile.username);
$('#password').val('');
$('#privatekey').val('');
$('#passphrase').val('');
$('#totp').val('');
$('#directory').val(profile.directory || '');
term_type.val(profile.term || 'xterm-256color');
apply_saved_password_hint(profile.has_password);
status.text('');
}
function apply_saved_password_hint(has_password) {
// the server never sends saved passwords to the browser; an empty
// field just tells it to use the one it already has on disk
var password = $('#password');
password.val('');
password.attr(
'placeholder',
has_password ? '已保存密码,留空即可直接连接' : ''
);
}
function delete_saved_connection() {
var profile = selected_connection();
if (!profile) {
@@ -296,8 +325,18 @@ jQuery(function($){
function toggle_fullscreen(term) {
$('#terminal .terminal').toggleClass('fullscreen');
$('#terminal').toggleClass('terminal-fullscreen');
term.fitAddon.fit();
resize_terminal(term);
}
function terminal_size() {
var terminal = document.getElementById('terminal'),
rect = terminal.getBoundingClientRect(),
width = rect.width || window.innerWidth,
height = rect.height || window.innerHeight;
return {'width': width, 'height': height};
}
@@ -310,8 +349,9 @@ jQuery(function($){
}
}
var cols = parseInt(window.innerWidth / style.width, 10) - 1;
var rows = parseInt(window.innerHeight / style.height, 10);
var size = terminal_size(),
cols = Math.max(2, parseInt(size.width / style.width, 10)),
rows = Math.max(1, parseInt(size.height / style.height, 10));
return {'cols': cols, 'rows': rows};
}
@@ -386,6 +426,57 @@ jQuery(function($){
}
function show_terminal_toast(text) {
terminal_toast.text(text).addClass('visible');
window.clearTimeout(toast_timer);
toast_timer = window.setTimeout(function() {
terminal_toast.removeClass('visible');
}, 1500);
}
function fallback_copy_text(text) {
var textarea = document.createElement('textarea'),
copied = false;
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
copied = document.execCommand('copy');
} catch (e) {
console.error(e);
}
document.body.removeChild(textarea);
return copied;
}
function copy_text_to_clipboard(text, callback) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function() { callback(true); },
function() { callback(fallback_copy_text(text)); }
);
} else {
callback(fallback_copy_text(text));
}
}
function read_clipboard_text(on_text, on_error) {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(on_text, on_error);
return true;
} else {
return false;
}
}
function read_as_text_with_decoder(file, callback, decoder) {
var reader = new window.FileReader();
@@ -455,9 +546,42 @@ jQuery(function($){
}
function clear_terminal() {
if (current_terminal) {
try {
current_terminal.dispose();
} catch (e) {
console.error(e);
}
current_terminal = undefined;
}
$('#terminal').removeClass('terminal-fullscreen').empty();
quickbar.hide();
$('body').removeClass('quickbar-open');
}
function write_disconnect_message(term, reason) {
var text = translate_status(reason) || '连接已断开。';
if (term) {
try {
term.write('\r\n\r\n\x1b[33m' + text + '\x1b[0m\r\n');
term.setOption('disableStdin', true);
} catch (e) {
console.error(e);
}
}
return text;
}
function log_status(text, to_populate) {
text = translate_status(text);
console.log(text);
if (text) {
console.log(text);
}
status.text(text);
if (to_populate && validated_form_data) {
@@ -490,7 +614,17 @@ jQuery(function($){
['Missing value username', '请输入 SSH 用户名。'],
['Missing argument hostname', '缺少主机地址。'],
['Missing argument username', '缺少 SSH 用户名。'],
['Need a verification code for 2fa.', '需要输入动态验证码。']
['Need a verification code for 2fa.', '需要输入动态验证码。'],
['Login required.', '登录状态已失效,请重新登录。'],
['Websocket authentication failed.', 'WebSocket 认证失败。'],
['websocket closed', 'WebSocket 连接已关闭。'],
['client disconnected', '客户端连接已断开。'],
['chan closed', 'SSH 会话已关闭。'],
['chan error on reading', '读取 SSH 会话时连接中断。'],
['chan error on writing', '写入 SSH 会话时连接中断。'],
['worker recycled', '连接等待超时,已被回收。'],
['Worker closed', 'SSH 工作连接已关闭。'],
['No worker found', '未找到活动的 SSH 连接。']
];
var i, item;
@@ -513,6 +647,9 @@ jQuery(function($){
if (text.indexOf('Invalid private key: ') === 0) {
return '私钥文件无效:' + text.slice('Invalid private key: '.length);
}
if (text.indexOf('Invalid directory: ') === 0) {
return '初始目录无效:' + text.slice('Invalid directory: '.length);
}
return text;
}
@@ -561,7 +698,9 @@ jQuery(function($){
}
}
clear_terminal();
var term = new window.Terminal(termOptions);
current_terminal = term;
term.fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(term.fitAddon);
@@ -706,8 +845,132 @@ jQuery(function($){
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();
@@ -729,17 +992,23 @@ jQuery(function($){
};
sock.onclose = function(e) {
term.dispose();
term = undefined;
var reason = write_disconnect_message(term, e.reason);
$('#terminal').removeClass('terminal-fullscreen');
quickbar.hide();
$('body').removeClass('quickbar-open');
sock = undefined;
reset_wssh();
log_status(e.reason, true);
log_status(reason, true);
term = undefined;
state = DISCONNECTED;
default_title = 'WebSSH';
title_element.text = default_title;
};
$(window).resize(function(){
// 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);
}
@@ -783,6 +1052,7 @@ jQuery(function($){
port = data.get('port'),
username = data.get('username'),
pk = data.get('privatekey'),
directory = data.get('directory'),
result = {
valid: false,
data: data,
@@ -817,6 +1087,13 @@ jQuery(function($){
}
}
if (directory) {
if (directory.length > directory_max_size ||
/[\x00-\x1f\x7f]/.test(directory)) {
errors.push('初始目录无效:' + directory);
}
}
if (!errors.length || debug) {
result.valid = true;
result.title = username + '@' + hostname + ':' + port;
@@ -929,7 +1206,7 @@ jQuery(function($){
}
function connect(hostname, port, username, password, privatekey, passphrase, totp) {
function connect(hostname, port, username, password, privatekey, passphrase, totp, directory) {
// for console use
var result, opts;
@@ -949,7 +1226,8 @@ jQuery(function($){
password: password,
privatekey: privatekey,
passphrase: passphrase,
totp: totp
totp: totp,
directory: directory
};
} else {
opts = hostname;
@@ -975,6 +1253,14 @@ jQuery(function($){
connect();
});
$(form_id).on('reset', function() {
window.setTimeout(function() {
update_password_visibility();
saved_connections_select.val('');
apply_saved_password_hint(false);
}, 0);
});
load_connection_button.click(function() {
apply_saved_connection();
});
@@ -987,23 +1273,62 @@ jQuery(function($){
delete_saved_connection();
});
show_password_checkbox.change(function() {
update_password_visibility();
});
function is_connect_options(data) {
return data && typeof data === 'object' && !Array.isArray(data) && (
data.hostname ||
data.port ||
data.username ||
data.password ||
data.privatekey ||
data.passphrase ||
data.totp ||
data.url
);
}
function parse_connect_message(data) {
var args;
if (typeof data === 'string') {
try {
args = JSON.parse(data);
} catch (e) {
args = data.split('|');
}
} else {
args = data;
}
if (Array.isArray(args) || typeof args === 'string') {
return args;
}
if (is_connect_options(args)) {
return args;
}
}
function cross_origin_connect(event)
{
console.log(event.origin);
var prop = 'connect',
args;
args = parse_connect_message(event.data);
try {
args = JSON.parse(event.data);
} catch (SyntaxError) {
args = event.data.split('|');
if (args === undefined) {
return;
}
if (!Array.isArray(args)) {
args = [args];
}
console.log(event.origin);
try {
event_origin = event.origin;
wssh[prop].apply(wssh, args);
+71 -5
View File
@@ -4,20 +4,27 @@ import os
import threading
import time
from webssh.crypto import SecretBox, is_encrypted
DEFAULT_PORT = 22
# keys that must never be handed out to a browser
PRIVATE_FIELDS = ('password', 'password_enc')
class ConnectionStore(object):
def __init__(self, filename):
def __init__(self, filename, secret=''):
self.filename = filename
self.lock = threading.Lock()
self.box = SecretBox(secret) if secret else None
def list(self, owner):
data = self._read()
profiles = [
profile for profile in data.get('connections', [])
self._public(profile)
for profile in data.get('connections', [])
if profile.get('owner') == owner
]
return sorted(
@@ -26,6 +33,24 @@ class ConnectionStore(object):
reverse=True
)
def get_password(self, owner, profile_id):
"""Return the stored password, never exposed through list()."""
for profile in self._read().get('connections', []):
if profile.get('owner') != owner:
continue
if profile.get('id') != profile_id:
continue
encrypted = profile.get('password_enc')
if encrypted:
return self.box.decrypt(encrypted) if self.box else ''
# written before the store started encrypting
return profile.get('password') or ''
return ''
def make_id(self, owner, hostname, port, username):
return self._make_id(owner, hostname, port, username)
def upsert(self, owner, connection):
profile = self._normalize(owner, connection)
@@ -51,7 +76,7 @@ class ConnectionStore(object):
self._write(data)
return profile
return self._public(profile)
def delete(self, owner, profile_id):
with self.lock:
@@ -69,17 +94,50 @@ class ConnectionStore(object):
self._write(data)
return deleted
def encrypt_plaintext_passwords(self):
"""Upgrade files written before passwords were encrypted at rest."""
if not self.box:
return 0
with self.lock:
data = self._read()
changed = 0
for profile in data.get('connections', []):
password = profile.pop('password', '')
if not password or is_encrypted(profile.get('password_enc')):
continue
profile['password_enc'] = self.box.encrypt(password)
changed += 1
if changed:
self._write(data)
return changed
def _public(self, profile):
public = {
key: value for key, value in profile.items()
if key not in PRIVATE_FIELDS
}
public['has_password'] = bool(
profile.get('password_enc') or profile.get('password')
)
return public
def _normalize(self, owner, connection):
hostname = (connection.get('hostname') or '').strip()
username = (connection.get('username') or '').strip()
term = (connection.get('term') or 'xterm-256color').strip()
directory = (connection.get('directory') or '').strip()
port = connection.get('port') or DEFAULT_PORT
port = int(port)
password = connection.get('password')
if password is None:
password = ''
title = '{}@{}:{}'.format(username, hostname, port)
auth_type = connection.get('auth_type') or 'password'
profile_id = self._make_id(owner, hostname, port, username)
return {
profile = {
'id': profile_id,
'owner': owner,
'title': title,
@@ -87,8 +145,17 @@ class ConnectionStore(object):
'port': port,
'username': username,
'term': term,
'directory': directory,
'auth_type': auth_type
}
if password:
if self.box:
profile['password_enc'] = self.box.encrypt(password)
else:
# no server secret to encrypt with; keep the legacy layout so
# encrypt_plaintext_passwords() can upgrade it later
profile['password'] = password
return profile
def _make_id(self, owner, hostname, port, username):
raw = '\0'.join([owner, hostname, str(port), username])
@@ -129,4 +196,3 @@ def _chmod_private(path):
os.chmod(path, 0o600)
except OSError:
pass
+46 -13
View File
@@ -4,16 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebSSH</title>
<link href="static/img/favicon.png" rel="icon" type="image/png">
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/xterm.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/fullscreen.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/app.css" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('img/favicon.png') }}" rel="icon" type="image/png">
<link href="{{ static_url('css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('css/xterm.min.css') }}" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('css/fullscreen.min.css') }}" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('css/app.css') }}" rel="stylesheet" type="text/css"/>
{% if font.family %}
<style>
@font-face {
font-family: '{{ font.family }}';
src: url('{{ font.url }}');
src: url('{{ static_url(font.path) }}');
}
body {
@@ -81,7 +81,13 @@
name="username" value="" required>
</div>
<div class="field">
<label for="password">SSH 密码</label>
<div class="field-heading">
<label for="password">SSH 密码</label>
<label class="inline-toggle" for="show-password">
<input type="checkbox" id="show-password">
<span>显示</span>
</label>
</div>
<input class="form-control" type="password" id="password"
name="password" value="">
</div>
@@ -100,6 +106,14 @@
<input class="form-control" type="password" id="totp"
name="totp" value="">
</div>
<div class="field">
<label for="directory">初始目录</label>
<input class="form-control" type="text" id="directory"
name="directory" value="" placeholder="/var/www"
autocomplete="off" autocapitalize="off"
autocorrect="off" spellcheck="false">
<small class="field-hint">登录后自动 cd 到该目录,留空则不切换</small>
</div>
<div class="field save-field">
<label class="save-toggle" for="save">
<input type="checkbox" id="save" name="save" value="1" checked>
@@ -123,11 +137,30 @@
<div id="terminal"></div>
</section>
<script src="static/js/jquery.min.js"></script>
<script src="static/js/popper.min.js"></script>
<script src="static/js/bootstrap.min.js"></script>
<script src="static/js/xterm.min.js"></script>
<script src="static/js/xterm-addon-fit.min.js"></script>
<script src="static/js/main.js"></script>
<div id="terminal-toast" class="terminal-toast" role="status"></div>
<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="paste">粘贴</button>
<span class="quickbar-sep" aria-hidden="true"></span>
<button type="button" class="quickbar-btn" data-key="ctrl-c">Ctrl+C</button>
<button type="button" class="quickbar-btn" data-key="tab">Tab</button>
<button type="button" class="quickbar-btn" data-key="esc">Esc</button>
<span class="quickbar-sep" aria-hidden="true"></span>
<button type="button" class="quickbar-btn" data-key="-">-</button>
<button type="button" class="quickbar-btn" data-key="/">/</button>
<button type="button" class="quickbar-btn" data-key=".">.</button>
<button type="button" class="quickbar-btn" data-key="~">~</button>
<button type="button" class="quickbar-btn" data-key="|">|</button>
<button type="button" class="quickbar-btn" data-key=":">:</button>
<button type="button" class="quickbar-btn" data-key="_">_</button>
</div>
<script src="{{ static_url('js/jquery.min.js') }}"></script>
<script src="{{ static_url('js/popper.min.js') }}"></script>
<script src="{{ static_url('js/bootstrap.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/main.js') }}"></script>
</body>
</html>
+3 -3
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebSSH 登录</title>
<link href="static/img/favicon.png" rel="icon" type="image/png">
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/app.css" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('img/favicon.png') }}" rel="icon" type="image/png">
<link href="{{ static_url('css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
<link href="{{ static_url('css/app.css') }}" rel="stylesheet" type="text/css"/>
</head>
<body class="login-page">
<main class="login-shell">
+18
View File
@@ -14,6 +14,9 @@ except ImportError:
numeric = re.compile(r'[0-9]+$')
allowed = re.compile(r'(?!-)[a-z0-9-]{1,63}(?<!-)$', re.IGNORECASE)
control_chars = re.compile(r'[\x00-\x1f\x7f]')
MAX_DIRECTORY_LENGTH = 1024
def to_str(bstr, encoding='utf-8'):
@@ -90,6 +93,21 @@ def is_valid_hostname(hostname):
return all(allowed.match(label) for label in labels)
def is_valid_directory(directory):
if not directory or len(directory) > MAX_DIRECTORY_LENGTH:
return False
return not control_chars.search(directory)
def quote_shell_arg(value):
"""Wrap a value in single quotes so a POSIX shell treats it literally."""
return "'{}'".format(value.replace("'", "'\\''"))
def build_cd_command(directory):
return 'cd {}\r'.format(quote_shell_arg(directory))
def is_same_primary_domain(domain1, domain2):
i = -1
dots = 0
+9 -8
View File
@@ -12,19 +12,18 @@ from tornado.util import errno_from_exception
BUF_SIZE = 32 * 1024
clients = {} # {ip: {id: worker}}
clients = {} # {client_key: {id: worker}}
def clear_worker(worker, clients):
ip = worker.src_addr[0]
workers = clients.get(ip)
assert worker.id in workers
workers.pop(worker.id)
key = worker.client_key
workers = clients.get(key)
if not workers or worker.id not in workers:
return
workers.pop(worker.id)
if not workers:
clients.pop(ip)
if not clients:
clients.clear()
clients.pop(key, None)
def recycle_worker(worker):
@@ -46,6 +45,8 @@ class Worker(object):
self.handler = None
self.mode = IOLoop.READ
self.closed = False
self.src_addr = None
self.client_key = None
def __call__(self, fd, events):
if events & IOLoop.READ: