From 845c3dece3a346a5c4aab2e1588cd64984856dcd Mon Sep 17 00:00:00 2001 From: jocayn <1579649885@qq.com> Date: Sat, 13 Jun 2026 12:11:36 +0800 Subject: [PATCH] Prepare WebSSH for self-hosted deployment --- .dockerignore | 11 + .gitignore | 73 +------ CLAUDE.md | 61 ++++++ Dockerfile | 23 ++- README.md | 34 +++- README.rst | 34 +++- docker-compose.yml | 10 +- tests/test_app.py | 2 + tests/test_auth.py | 36 ++++ tests/test_auth_app.py | 64 ++++++ tests/test_storage.py | 46 +++++ webssh/auth.py | 153 ++++++++++++++ webssh/handler.py | 186 ++++++++++++++++- webssh/main.py | 9 +- webssh/settings.py | 78 ++++++++ webssh/static/css/app.css | 388 ++++++++++++++++++++++++++++++++++++ webssh/static/js/main.js | 243 +++++++++++++++++++--- webssh/storage.py | 132 ++++++++++++ webssh/templates/index.html | 174 +++++++++------- webssh/templates/login.html | 54 +++++ 20 files changed, 1628 insertions(+), 183 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/test_auth.py create mode 100644 tests/test_auth_app.py create mode 100644 tests/test_storage.py create mode 100644 webssh/auth.py create mode 100644 webssh/static/css/app.css create mode 100644 webssh/storage.py create mode 100644 webssh/templates/login.html diff --git a/.dockerignore b/.dockerignore index 6b8710a..61df577 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,12 @@ .git +.github +.venv +__pycache__ +*.pyc +.pytest_cache +.coverage +build +dist +*.egg-info +.webssh-data +.webssh-server.log* diff --git a/.gitignore b/.gitignore index 661e920..13dc735 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,12 @@ -# Byte-compiled / optimized / DLL files +.venv/ +.webssh-data/ +.webssh-server*.log +/auth.json +/cookie_secret +/known_hosts +tests/data/host_keys_test.db +tests/data/sshserver.log __pycache__/ -*.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.cache +*.pyc .pytest_cache/ -nosetests.xml -coverage.xml - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# database file -*.sqlite -*.sqlite3 -*.db - -# temporary file -*.swp - -# known_hosts file -known_hosts +.coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a75586d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +WebSSH is a web-based SSH client. A Tornado async server handles HTTP/WebSocket connections and tunnels terminal I/O to remote SSH servers via Paramiko. The browser renders the terminal using xterm.js. + +## Development Commands + +```bash +# Install +pip install -r requirements.txt +pip install -e . # editable install (provides `wssh` CLI entry point) + +# Run +python run.py # starts on default port 8888 +python run.py --port=8888 --debug=True --data-dir=./data + +# Lint +ruff check . # or: flake8 . (max-line-length=79, see setup.cfg) + +# Test +pytest # all tests +pytest --cov=webssh # with coverage +pytest tests/test_app.py # single file +``` + +## Architecture + +``` +Browser (xterm.js + WebSocket) <--> Tornado (WsockHandler) <--> Paramiko SSH channel <--> Remote SSH server +``` + +**Backend (`webssh/`)** +- `main.py` — entry point; wires routes, parses CLI options, starts Tornado IOLoop. +- `handler.py` — all Tornado request handlers. `MixinHandler` is the base mixin providing IP filtering, XSRF, origin checks, and session auth. Key handlers: `IndexHandler` (serves UI on GET, initiates SSH on POST), `WsockHandler` (WebSocket relay), `LoginHandler`/`LogoutHandler`, `ConnectionsHandler`/`ConnectionHandler`. +- `worker.py` — `Worker` class manages the Paramiko SSH channel lifecycle and registers its socket fd on Tornado's IOLoop for async read/write. +- `settings.py` — CLI option definitions (Tornado `define()`), host key loading, SSL context setup, font defaults. +- `auth.py` — admin user setup and password verification using PBKDF2-SHA256. +- `storage.py` — persistent saved-connections store (`connections.json`) with file locking. +- `policy.py` — thread-safe `AutoAddPolicy` wrapper for Paramiko host key validation. +- `utils.py` — input parsing/validation helpers (`to_str`, `to_bytes`, `to_int`, `to_ip_address`, hostname/port validators). + +**Frontend (`webssh/static/js/main.js`, `webssh/templates/`)** +- jQuery 3 + Bootstrap 4 UI, xterm.js terminal, WebSocket bridge. +- `index.html` — terminal page; `login.html` — auth page. + +**Data directory** (`~/.webssh` or `--data-dir`): stores `auth.json`, `connections.json`, cookie secret. + +## Key Design Constraints + +- **Async safety**: Paramiko SSH connections are blocking. Always run them via `IndexHandler.executor` (ThreadPoolExecutor) to avoid blocking the Tornado IOLoop. +- **Thread safety**: File writes to `auth.json`/`connections.json` and Paramiko host-key mutations must use `threading.Lock`. +- **New endpoints**: Subclass `MixinHandler`. Use `@tornado.web.authenticated` for protected routes. +- **Python 3.10+** required (see setup.py classifiers). +- **File permissions**: Database files (`auth.json`, `connections.json`) are created with mode `0o600`. + +## Testing + +Tests use pytest. `tests/sshserver.py` spins up a local mock SSH server (Paramiko-based) for integration testing of handler connections, password auth, key auth, and 2FA. diff --git a/Dockerfile b/Dockerfile index cbf7f71..9661848 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,21 @@ -FROM python:3-alpine +FROM python:3.12-slim LABEL maintainer='' LABEL version='0.0.0-dev.0-build.0' -ADD . /code +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + WORKDIR /code -RUN \ - apk add --no-cache libc-dev libffi-dev gcc && \ - pip install -r requirements.txt --no-cache-dir && \ - apk del gcc libc-dev libffi-dev && \ - addgroup webssh && \ - adduser -Ss /bin/false -g webssh webssh && \ - chown -R webssh:webssh /code + +COPY requirements.txt /code/ +RUN pip install -r requirements.txt --no-cache-dir + +COPY . /code +RUN useradd --system --create-home --shell /usr/sbin/nologin webssh && \ + mkdir -p /data && \ + chown -R webssh:webssh /code /data EXPOSE 8888/tcp USER webssh -CMD ["python", "run.py"] +CMD ["python", "run.py", "--address=0.0.0.0", "--port=8888", "--data-dir=/data"] diff --git a/README.md b/README.md index 2db5dac..5a17e5a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,29 @@ A simple web application to be used as an ssh client to connect to your ssh serv 1. Install this app, run command `pip install webssh` 2. Start a webserver, run command `wssh` 3. Open your browser, navigate to `127.0.0.1:8888` -4. Input your data, submit the form. +4. Create the WebSSH administrator account on first visit +5. Input your ssh data, submit the form. + + +### WebSSH login and saved connections + +WebSSH requires a login before opening ssh sessions. On first startup, if no +administrator password has been configured, the login page will ask you to +create one. The password is stored as a PBKDF2 hash under the data directory, +not as plain text. + +You can also provide credentials with environment variables: + +```bash +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. + +Use `--auth=false` only for a trusted private deployment where another layer +already protects access to WebSSH. ### Server options @@ -66,6 +88,9 @@ wssh --logging=debug # log to file wssh --log-file-prefix=main.log +# data directory for login and saved connection data +wssh --data-dir='/path/to/webssh-data' + # more options wssh --help ``` @@ -153,14 +178,17 @@ http://localhost:8888/?term=xterm-256color Start up the app ``` -docker-compose up +docker compose up -d ``` Tear down the app ``` -docker-compose down +docker compose down ``` +The bundled compose file persists login and saved connection data in the +`webssh-data` volume mounted at `/data` inside the container. + ### Tests Requirements diff --git a/README.rst b/README.rst index aa51372..d6a3ccf 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,29 @@ Quickstart 1. Install this app, run command ``pip install webssh`` 2. Start a webserver, run command ``wssh`` 3. Open your browser, navigate to ``127.0.0.1:8888`` -4. Input your data, submit the form. +4. Create the WebSSH administrator account on first visit +5. Input your ssh data, submit the form. + +WebSSH login and saved connections +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +WebSSH requires a login before opening ssh sessions. On first startup, if +no administrator password has been configured, the login page will ask you +to create one. The password is stored as a PBKDF2 hash under the data +directory, not as plain text. + +You can also provide credentials with environment variables: + +.. code:: bash + + 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. + +Use ``--auth=false`` only for a trusted private deployment where another +layer already protects access to WebSSH. Server options ~~~~~~~~~~~~~~ @@ -71,6 +93,9 @@ Server options # log to file wssh --log-file-prefix=main.log + # data directory for login and saved connection data + wssh --data-dir='/path/to/webssh-data' + # more options wssh --help @@ -162,13 +187,16 @@ Start up the app :: - docker-compose up + docker compose up -d Tear down the app :: - docker-compose down + docker compose down + +The bundled compose file persists login and saved connection data in the +``webssh-data`` volume mounted at ``/data`` inside the container. Tests ~~~~~ diff --git a/docker-compose.yml b/docker-compose.yml index 315cee6..5b0ad37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,12 @@ version: '3' services: - web: + webssh: build: . + restart: unless-stopped ports: - - "8888:8888" + - "8888:8888" + volumes: + - webssh-data:/data + +volumes: + webssh-data: diff --git a/tests/test_app.py b/tests/test_app.py index bd31b5f..24ddd37 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -99,6 +99,7 @@ class TestAppBasic(TestAppBase): options.syshostfile = '' options.tdstream = '' options.delay = 0.1 + options.auth = False app = make_app(make_handlers(loop, options), get_app_settings(options)) return app @@ -536,6 +537,7 @@ class OtherTestBase(TestAppBase): options.tdstream = self.tdstream options.maxconn = self.maxconn options.origin = self.origin + options.auth = False app = make_app(make_handlers(loop, options), get_app_settings(options)) return app diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3267793 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,36 @@ +import os +import shutil +import tempfile +import unittest + +from webssh.auth import AuthManager, make_password_hash, verify_password + + +class TestAuth(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_password_hash(self): + encoded = make_password_hash('secret') + self.assertTrue(verify_password('secret', encoded)) + self.assertFalse(verify_password('wrong', encoded)) + self.assertFalse(verify_password('', encoded)) + + def test_auth_manager_setup_and_verify(self): + auth_file = os.path.join(self.tmpdir, 'auth.json') + manager = AuthManager(auth_file=auth_file) + self.assertFalse(manager.is_configured()) + + manager.setup('admin', 'secret') + self.assertTrue(manager.is_configured()) + self.assertTrue(manager.verify('admin', 'secret')) + self.assertFalse(manager.verify('admin', 'wrong')) + self.assertFalse(manager.verify('other', 'secret')) + + loaded = AuthManager(auth_file=auth_file) + self.assertTrue(loaded.verify('admin', 'secret')) + diff --git a/tests/test_auth_app.py b/tests/test_auth_app.py new file mode 100644 index 0000000..ce82fe3 --- /dev/null +++ b/tests/test_auth_app.py @@ -0,0 +1,64 @@ +import json +import shutil +import tempfile + +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.utils import to_str + + +class TestAppAuth(AsyncHTTPTestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + super(TestAppAuth, self).setUp() + + def tearDown(self): + super(TestAppAuth, self).tearDown() + shutil.rmtree(self.tmpdir) + + def get_app(self): + loop = self.io_loop + options.auth = True + options.auth_username = 'admin' + options.auth_password = '' + 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' + handlers = make_handlers(loop, options) + return make_app(handlers, get_app_settings(options)) + + def get_cookie_header(self, response): + cookie = response.headers.get_list('Set-Cookie')[0] + return {'Cookie': cookie.split(';', 1)[0]} + + def test_auth_setup_and_saved_connections_endpoint(self): + response = self.fetch('/', follow_redirects=False) + self.assertEqual(response.code, 302) + self.assertIn('/login', response.headers['Location']) + + response = self.fetch('/login') + self.assertIn('创建管理员账号'.encode('utf-8'), response.body) + + body = 'username=admin&password=secret&confirm=secret' + response = self.fetch('/login', method='POST', body=body, + follow_redirects=False) + self.assertEqual(response.code, 302) + headers = self.get_cookie_header(response) + + response = self.fetch('/', headers=headers) + self.assertIn('已保存连接'.encode('utf-8'), response.body) + + response = self.fetch('/connections', headers=headers) + data = json.loads(to_str(response.body)) + self.assertEqual(data, {'connections': []}) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..bc255ee --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,46 @@ +import os +import shutil +import tempfile +import unittest + +from webssh.storage import ConnectionStore + + +class TestConnectionStore(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.store = ConnectionStore(os.path.join(self.tmpdir, + 'connections.json')) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_upsert_list_and_delete(self): + profile = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'port': 22, + 'username': 'root', + 'term': 'xterm-256color', + 'auth_type': 'password' + }) + + self.assertEqual(profile['title'], 'root@127.0.0.1:22') + self.assertEqual([profile], self.store.list('admin')) + self.assertEqual([], self.store.list('other')) + + updated = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'port': 22, + 'username': 'root', + 'term': 'xterm', + 'auth_type': 'privatekey' + }) + + self.assertEqual(profile['id'], updated['id']) + self.assertEqual(1, len(self.store.list('admin'))) + self.assertEqual('xterm', self.store.list('admin')[0]['term']) + self.assertTrue(self.store.delete('admin', profile['id'])) + self.assertFalse(self.store.delete('admin', profile['id'])) + self.assertEqual([], self.store.list('admin')) + diff --git a/webssh/auth.py b/webssh/auth.py new file mode 100644 index 0000000..2c7373a --- /dev/null +++ b/webssh/auth.py @@ -0,0 +1,153 @@ +import base64 +import hashlib +import hmac +import json +import os +import threading + +try: + import secrets +except ImportError: + secrets = None + +try: + from uuid import uuid4 +except ImportError: + uuid4 = None + + +HASH_NAME = 'pbkdf2_sha256' +HASH_ITERATIONS = 260000 + + +def _token_urlsafe(nbytes=32): + if secrets: + return secrets.token_urlsafe(nbytes) + return uuid4().hex + + +def _b64encode(data): + return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=') + + +def make_password_hash(password, salt=None, iterations=HASH_ITERATIONS): + if not password: + raise ValueError('Password must not be empty.') + + if salt is None: + salt = _token_urlsafe(18) + + digest = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt.encode('utf-8'), + iterations + ) + return '{}${}${}${}'.format( + HASH_NAME, iterations, salt, _b64encode(digest) + ) + + +def verify_password(password, encoded): + try: + name, iterations, salt, _ = encoded.split('$', 3) + iterations = int(iterations) + except (AttributeError, TypeError, ValueError): + return False + + if name != HASH_NAME: + return False + + try: + actual = make_password_hash(password, salt, iterations) + except ValueError: + return False + + return hmac.compare_digest(actual, encoded) + + +def is_safe_username(username): + return bool(username and username.strip()) + + +class AuthManager(object): + + def __init__(self, username='admin', password='', password_hash='', + auth_file=''): + self.username = username or 'admin' + self.password_hash = password_hash + if password and not self.password_hash: + self.password_hash = make_password_hash(password) + self.auth_file = auth_file + self.lock = threading.Lock() + if not self.password_hash: + self.load() + + def is_configured(self): + return bool(self.password_hash) + + def is_valid_username(self, username): + return hmac.compare_digest(username or '', self.username or '') + + def load(self): + if not self.auth_file or not os.path.isfile(self.auth_file): + return + + with self.lock: + with open(self.auth_file, encoding='utf-8') as f: + data = json.load(f) + + username = data.get('username') + password_hash = data.get('password_hash') + if username and password_hash: + self.username = username + self.password_hash = password_hash + + def setup(self, username, password): + username = username.strip() + if not is_safe_username(username): + raise ValueError('用户名不能为空。') + + password_hash = make_password_hash(password) + data = { + 'username': username, + 'password_hash': password_hash + } + + with self.lock: + if self.password_hash: + raise ValueError('认证信息已配置。') + + dirname = os.path.dirname(self.auth_file) + if dirname and not os.path.isdir(dirname): + os.makedirs(dirname) + + tmp = self.auth_file + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write('\n') + _chmod_private(tmp) + os.replace(tmp, self.auth_file) + _chmod_private(self.auth_file) + + self.username = username + self.password_hash = password_hash + + def verify(self, username, password): + if not self.password_hash: + self.load() + + if not self.password_hash: + return False + + return ( + self.is_valid_username(username) and + verify_password(password, self.password_hash) + ) + + +def _chmod_private(path): + try: + os.chmod(path, 0o600) + except OSError: + pass diff --git a/webssh/handler.py b/webssh/handler.py index 6cfc822..89db9ec 100644 --- a/webssh/handler.py +++ b/webssh/handler.py @@ -265,6 +265,34 @@ class MixinHandler(object): port = '' if port == 443 else ':%s' % port return 'https://{}{}{}'.format(hostname, port, uri) + def get_current_user(self): + auth_manager = self.settings.get('auth_manager') + if not auth_manager: + return 'anonymous' + + username = self.get_secure_cookie('webssh_user') + if not username: + return + + username = to_str(username) + if auth_manager.is_valid_username(username): + return username + + def get_safe_next_url(self): + next_url = self.get_argument('next', u'/') + parsed = urlparse(next_url) + if parsed.scheme or parsed.netloc or not next_url.startswith('/'): + return '/' + return next_url + + @property + def auth_manager(self): + return self.settings.get('auth_manager') + + @property + def connection_store(self): + return self.settings.get('connection_store') + def set_default_headers(self): for header in self.custom_headers.items(): self.set_header(*header) @@ -303,6 +331,118 @@ class MixinHandler(object): return (ip, port) +class LoginHandler(MixinHandler, tornado.web.RequestHandler): + + def initialize(self): + super(LoginHandler, self).initialize() + + def get(self): + manager = self.auth_manager + if not manager: + self.redirect('/') + return + + if manager.is_configured() and self.current_user: + self.redirect(self.get_safe_next_url()) + return + + self.render_login() + + def post(self): + manager = self.auth_manager + if not manager: + self.redirect('/') + return + + username = self.get_argument('username', u'').strip() + password = self.get_argument('password', u'') + + if manager.is_configured(): + if manager.verify(username, password): + self.set_login_cookie(username) + self.redirect(self.get_safe_next_url()) + else: + self.render_login('用户名或密码错误。') + return + + confirm = self.get_argument('confirm', u'') + if not username: + self.render_login('用户名不能为空。') + return + if not password: + self.render_login('密码不能为空。') + return + if password != confirm: + self.render_login('两次输入的密码不一致。') + return + + try: + manager.setup(username, password) + except ValueError as exc: + self.render_login(str(exc)) + else: + self.set_login_cookie(username) + self.redirect(self.get_safe_next_url()) + + def set_login_cookie(self, username): + self.set_secure_cookie( + 'webssh_user', + username, + expires_days=self.settings.get('auth_session_days', 7), + httponly=True, + secure=self.request.protocol == 'https', + samesite='Lax' + ) + + def render_login(self, error=''): + manager = self.auth_manager + self.render( + 'login.html', + error=error, + setup=not manager.is_configured(), + username=manager.username, + next_url=self.get_safe_next_url() + ) + + +class LogoutHandler(MixinHandler, tornado.web.RequestHandler): + + def initialize(self): + super(LogoutHandler, self).initialize() + + def get(self): + self.clear_cookie('webssh_user') + self.redirect('/login') + + def post(self): + self.get() + + +class ConnectionsHandler(MixinHandler, tornado.web.RequestHandler): + + def initialize(self): + super(ConnectionsHandler, self).initialize() + + @tornado.web.authenticated + def get(self): + self.write({ + 'connections': self.connection_store.list(self.current_user) + }) + + +class ConnectionHandler(MixinHandler, tornado.web.RequestHandler): + + def initialize(self): + super(ConnectionHandler, self).initialize() + + @tornado.web.authenticated + def delete(self, profile_id): + deleted = self.connection_store.delete(self.current_user, profile_id) + if not deleted: + raise tornado.web.HTTPError(404) + self.write({'status': 'ok'}) + + class NotFoundHandler(MixinHandler, tornado.web.ErrorHandler): def initialize(self): @@ -395,6 +535,7 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): privatekey, filename = self.get_privatekey() passphrase = self.get_argument('passphrase', u'') totp = self.get_argument('totp', u'') + term = self.get_argument('term', u'') or u'xterm' if isinstance(self.policy, paramiko.RejectPolicy): self.lookup_hostname(hostname, port) @@ -405,8 +546,15 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): pkey = None self.ssh_client.totp = totp - args = (hostname, port, username, password, pkey) - logging.debug(args) + args = (hostname, port, username, password, pkey, term) + self.connection_info = { + 'hostname': hostname, + 'port': port, + 'username': username, + 'term': term, + 'auth_type': 'privatekey' if privatekey else 'password' + } + logging.debug(args[:5]) return args @@ -449,10 +597,12 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): def ssh_connect(self, args): ssh = self.ssh_client dst_addr = args[:2] + connect_args = args[:5] + term = args[5] logging.info('Connecting to {}:{}'.format(*dst_addr)) try: - ssh.connect(*args, timeout=options.timeout) + ssh.connect(*connect_args, timeout=options.timeout) except socket.error: raise ValueError('Unable to connect to {}:{}'.format(*dst_addr)) except paramiko.BadAuthenticationType: @@ -462,7 +612,6 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): except paramiko.BadHostKeyException: raise ValueError('Bad host key.') - term = self.get_argument('term', u'') or u'xterm' chan = ssh.invoke_shell(term=term) chan.setblocking(0) worker = Worker(self.loop, ssh, chan, dst_addr) @@ -484,13 +633,22 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): if not event_origin and self.origin_policy != 'same': self.set_header('Access-Control-Allow-Origin', origin) + @tornado.web.authenticated def head(self): pass + @tornado.web.authenticated def get(self): - self.render('index.html', debug=self.debug, font=self.font) + self.render( + 'index.html', + debug=self.debug, + font=self.font, + user=self.current_user, + auth_enabled=bool(self.auth_manager) + ) @tornado.gen.coroutine + @tornado.web.authenticated def post(self): if self.debug and self.get_argument('error', u''): # for testing purpose only @@ -522,9 +680,23 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): workers[worker.id] = worker self.loop.call_later(options.delay, recycle_worker, worker) self.result.update(id=worker.id, encoding=worker.encoding) + profile = self.save_connection() + if profile: + self.result.update(profile=profile) self.write(self.result) + def save_connection(self): + if not self.get_argument('save', u''): + return + + try: + return self.connection_store.upsert( + self.current_user, self.connection_info + ) + except Exception: + logging.error(traceback.format_exc()) + class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): @@ -533,6 +705,10 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): self.worker_ref = None def open(self): + if not self.current_user: + self.close(reason='Login required.') + return + self.src_addr = self.get_client_addr() logging.info('Connected from {}:{}'.format(*self.src_addr)) diff --git a/webssh/main.py b/webssh/main.py index 5faad10..4ef4d94 100644 --- a/webssh/main.py +++ b/webssh/main.py @@ -4,7 +4,10 @@ import tornado.ioloop from tornado.options import options from webssh import handler -from webssh.handler import IndexHandler, WsockHandler, NotFoundHandler +from webssh.handler import ( + ConnectionHandler, ConnectionsHandler, IndexHandler, LoginHandler, + LogoutHandler, WsockHandler, NotFoundHandler +) from webssh.settings import ( get_app_settings, get_host_keys_settings, get_policy_setting, get_ssl_context, get_server_settings, check_encoding_setting @@ -16,6 +19,10 @@ def make_handlers(loop, options): policy = get_policy_setting(options, host_keys_settings) handlers = [ + (r'/login', LoginHandler), + (r'/logout', LogoutHandler), + (r'/connections', ConnectionsHandler), + (r'/connections/([a-f0-9]{24})', ConnectionHandler), (r'/', IndexHandler, dict(loop=loop, policy=policy, host_keys_settings=host_keys_settings)), (r'/ws', WsockHandler, dict(loop=loop)) diff --git a/webssh/settings.py b/webssh/settings.py index b02b79e..281a8b5 100644 --- a/webssh/settings.py +++ b/webssh/settings.py @@ -1,12 +1,15 @@ import logging import os.path +import secrets import ssl import sys from tornado.options import define +from webssh.auth import AuthManager from webssh.policy import ( load_host_keys, get_policy_class, check_policy_setting ) +from webssh.storage import ConnectionStore from webssh.utils import ( to_ip_address, parse_origin_from_url, is_valid_encoding ) @@ -51,6 +54,24 @@ define('font', default='', help='custom font filename') define('encoding', default='', help='''The default character encoding of ssh servers. Example: --encoding='utf-8' to solve the problem with some switches&routers''') +define('auth', type=bool, default=True, + help='Require a WebSSH login before connecting to ssh servers') +define('auth_username', default=os.environ.get('WEBSSH_AUTH_USERNAME', + 'admin'), + help='WebSSH login username used before first-time setup') +define('auth_password', default=os.environ.get('WEBSSH_AUTH_PASSWORD', ''), + help='WebSSH login password, preferably supplied by environment') +define('auth_password_hash', + default=os.environ.get('WEBSSH_AUTH_PASSWORD_HASH', ''), + help='PBKDF2 password hash for WebSSH login') +define('auth_session_days', type=int, default=7, + help='Days before the WebSSH login session expires') +define('data_dir', + default=os.environ.get( + 'WEBSSH_DATA_DIR', + os.path.join(os.path.expanduser('~'), '.webssh') + ), + help='Directory used for WebSSH auth and saved connection data') define('version', type=bool, help='Show version information', callback=print_version) @@ -74,12 +95,17 @@ class Font(object): def get_app_settings(options): + auth_manager = get_auth_manager(options) settings = dict( template_path=os.path.join(base_dir, 'webssh', 'templates'), static_path=os.path.join(base_dir, 'webssh', 'static'), websocket_ping_interval=options.wpintvl, debug=options.debug, xsrf_cookies=options.xsrf, + login_url='/login', + auth_manager=auth_manager, + auth_session_days=options.auth_session_days, + connection_store=get_connection_store(options), font=Font( get_font_filename(options.font, os.path.join(base_dir, *font_dirs)), @@ -87,6 +113,8 @@ def get_app_settings(options): ), origin_policy=get_origin_setting(options) ) + if auth_manager: + settings['cookie_secret'] = get_cookie_secret(options) return settings @@ -196,3 +224,53 @@ def get_font_filename(font, font_dir): def check_encoding_setting(encoding): if encoding and not is_valid_encoding(encoding): raise ValueError('Unknown character encoding {!r}.'.format(encoding)) + + +def get_auth_manager(options): + if not options.auth: + return + + auth_file = os.path.join( + os.path.expanduser(options.data_dir), 'auth.json' + ) + return AuthManager( + username=options.auth_username, + password=options.auth_password, + password_hash=options.auth_password_hash, + auth_file=auth_file + ) + + +def get_connection_store(options): + filename = os.path.join( + os.path.expanduser(options.data_dir), 'connections.json' + ) + return ConnectionStore(filename) + + +def get_cookie_secret(options): + secret = os.environ.get('WEBSSH_COOKIE_SECRET', '') + if secret: + return secret + + filename = os.path.join( + os.path.expanduser(options.data_dir), 'cookie_secret' + ) + if os.path.isfile(filename): + with open(filename, encoding='utf-8') as f: + secret = f.read().strip() + if secret: + return secret + + dirname = os.path.dirname(filename) + if dirname and not os.path.isdir(dirname): + os.makedirs(dirname) + + secret = secrets.token_urlsafe(64) + with open(filename, 'w', encoding='utf-8') as f: + f.write(secret + '\n') + try: + os.chmod(filename, 0o600) + except OSError: + pass + return secret diff --git a/webssh/static/css/app.css b/webssh/static/css/app.css new file mode 100644 index 0000000..c70e438 --- /dev/null +++ b/webssh/static/css/app.css @@ -0,0 +1,388 @@ +:root { + --page-bg: #f5faf8; + --surface: #ffffff; + --surface-soft: #f1f8f6; + --text: #17252a; + --muted: #647371; + --line: #d8e7e3; + --brand: #2f9e8f; + --brand-dark: #21776d; + --accent: #3f73c9; + --danger: #d65a4a; + --danger-soft: #fff1ee; + --shadow: 0 18px 48px rgba(21, 58, 58, 0.09); +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; +} + +body { + min-height: 100vh; + margin: 0; + color: var(--text); + background: + linear-gradient(135deg, #f5fbf7 0%, #edf8fb 52%, #fffdf5 100%); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", + "PingFang SC", "Hiragino Sans GB", Arial, sans-serif; + letter-spacing: 0; +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + content: ""; + background-image: + linear-gradient(rgba(47, 158, 143, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(63, 115, 201, 0.06) 1px, transparent 1px); + background-size: 44px 44px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.68), transparent 72%); +} + +a { + color: var(--brand-dark); +} + +a:hover { + color: var(--brand); + text-decoration: none; +} + +.app-shell, +.login-shell { + width: min(100% - 32px, 1040px); + margin: 36px auto; +} + +.login-shell { + width: min(100% - 32px, 420px); + margin-top: 72px; +} + +.app-panel, +.login-panel { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(216, 231, 227, 0.9); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.app-panel { + overflow: hidden; +} + +.login-panel { + padding: 30px; +} + +.topbar { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + padding: 28px 30px 22px; +} + +.brand-kicker { + display: block; + margin-bottom: 7px; + color: var(--brand-dark); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} + +.brand-block h1, +.login-title h1 { + margin: 0; + color: var(--text); + font-size: 26px; + font-weight: 650; + line-height: 1.2; +} + +.session-meta { + display: flex; + gap: 14px; + align-items: center; + color: var(--muted); + font-size: 14px; +} + +.session-user { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.saved-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: end; + padding: 18px 30px; + background: rgba(241, 248, 246, 0.72); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.connection-form { + padding: 28px 30px 30px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 20px; +} + +.field label, +.form-group label { + display: block; + margin-bottom: 8px; + color: #314b50; + font-size: 13px; + font-weight: 650; +} + +.form-control { + min-height: 44px; + color: var(--text); + background-color: rgba(255, 255, 255, 0.96); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: none; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + background-color 0.16s ease; +} + +.form-control:focus { + background-color: #ffffff; + border-color: var(--brand); + box-shadow: 0 0 0 0.18rem rgba(47, 158, 143, 0.14); +} + +input[type="file"].form-control { + height: auto; + padding-top: 9px; + padding-bottom: 9px; +} + +.save-field { + display: flex; + align-items: end; +} + +.save-toggle { + display: inline-flex; + gap: 10px; + align-items: center; + min-height: 44px; + margin: 0; + color: #314b50; + cursor: pointer; +} + +.save-toggle input { + width: 18px; + height: 18px; + accent-color: var(--brand); +} + +.saved-actions, +.action-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.action-row { + margin-top: 26px; +} + +.btn { + min-height: 42px; + padding: 9px 18px; + border-radius: 8px; + font-weight: 650; + letter-spacing: 0; + box-shadow: none; +} + +.btn:focus { + box-shadow: 0 0 0 0.18rem rgba(47, 158, 143, 0.14); +} + +.btn-primary { + background: var(--brand); + border-color: var(--brand); +} + +.btn-primary:hover, +.btn-primary:disabled { + background: var(--brand-dark); + border-color: var(--brand-dark); +} + +.btn-secondary, +.btn-outline-secondary { + color: #29464b; + background: #ffffff; + border-color: var(--line); +} + +.btn-secondary:hover, +.btn-outline-secondary:hover { + color: var(--text); + background: var(--surface-soft); + border-color: #b7d5ce; +} + +.btn-outline-danger { + color: var(--danger); + border-color: rgba(214, 90, 74, 0.42); +} + +.btn-outline-danger:hover { + background: var(--danger); + border-color: var(--danger); +} + +.terminal-stage { + width: min(100% - 32px, 1040px); + margin: 18px auto 0; +} + +.status-message { + margin-bottom: 12px; + padding: 13px 16px; + color: #9c3327; + white-space: pre-line; + background: var(--danger-soft); + border: 1px solid rgba(214, 90, 74, 0.2); + border-radius: 8px; +} + +.status-message:empty { + display: none; +} + +#terminal { + min-height: 0; +} + +#waiter { + position: fixed; + inset: 0; + z-index: 20; + background: rgba(245, 250, 248, 0.86); + backdrop-filter: blur(6px); +} + +.waiter-card { + position: absolute; + top: 50%; + left: 50%; + display: inline-flex; + gap: 12px; + align-items: center; + padding: 14px 18px; + color: #29464b; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + transform: translate(-50%, -50%); +} + +.waiter-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(47, 158, 143, 0.22); + border-top-color: var(--brand); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.login-title { + margin-bottom: 24px; +} + +.login-title p { + margin: 9px 0 0; + color: var(--muted); + font-size: 14px; +} + +.form-group { + margin-bottom: 17px; +} + +.alert-danger { + color: #9c3327; + background: var(--danger-soft); + border-color: rgba(214, 90, 74, 0.24); + border-radius: 8px; +} + +.btn-block { + margin-top: 8px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .app-shell, + .terminal-stage, + .login-shell { + width: min(100% - 24px, 1040px); + margin-top: 18px; + } + + .login-shell { + margin-top: 36px; + } + + .topbar, + .saved-bar, + .connection-form, + .login-panel { + padding-right: 18px; + padding-left: 18px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .saved-bar, + .form-grid { + grid-template-columns: 1fr; + } + + .saved-actions, + .action-row { + align-items: stretch; + flex-direction: column; + } + + .saved-actions .btn, + .action-row .btn { + width: 100%; + } +} diff --git a/webssh/static/js/main.js b/webssh/static/js/main.js index 5ab0d8c..e57ee50 100644 --- a/webssh/static/js/main.js +++ b/webssh/static/js/main.js @@ -37,9 +37,12 @@ var wssh = {}; jQuery(function($){ var status = $('#status'), - button = $('.btn-primary'), + button = $('#connect .btn-primary'), form_container = $('.form-container'), waiter = $('#waiter'), + saved_connections_select = $('#saved-connections'), + load_connection_button = $('#load-connection'), + delete_connection_button = $('#delete-connection'), term_type = $('#term'), style = {}, default_title = 'WebSSH', @@ -52,13 +55,15 @@ jQuery(function($){ CONNECTING = 1, CONNECTED = 2, state = DISCONNECTED, - messages = {1: 'This client is connecting ...', 2: 'This client is already connnected.'}, + messages = {1: '正在连接,请稍候 ...', 2: '当前客户端已经连接。'}, key_max_size = 16384, fields = ['hostname', 'port', 'username'], form_keys = fields.concat(['password', 'totp']), opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'], url_form_data = {}, url_opts_data = {}, + saved_connections = [], + saved_connection_map = {}, validated_form_data, 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*$)/; @@ -90,6 +95,133 @@ jQuery(function($){ } + function get_xsrf_token() { + var input = document.querySelector('input[name="_xsrf"]'); + return input ? input.value : ''; + } + + + function set_saved_connections(connections) { + var i, profile; + + saved_connections = connections || []; + saved_connection_map = {}; + for (i = 0; i < saved_connections.length; i++) { + profile = saved_connections[i]; + saved_connection_map[profile.id] = profile; + } + render_saved_connections(); + } + + + function render_saved_connections(selected) { + var i, profile, option; + + saved_connections_select.empty(); + saved_connections_select.append( + $('