Prepare WebSSH for self-hosted deployment

This commit is contained in:
jocayn
2026-06-13 12:11:36 +08:00
parent aaac2afe2a
commit 845c3dece3
20 changed files with 1628 additions and 183 deletions
+11
View File
@@ -1 +1,12 @@
.git .git
.github
.venv
__pycache__
*.pyc
.pytest_cache
.coverage
build
dist
*.egg-info
.webssh-data
.webssh-server.log*
+10 -63
View File
@@ -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__/ __pycache__/
*.py[cod] *.pyc
# 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
.pytest_cache/ .pytest_cache/
nosetests.xml .coverage
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
+61
View File
@@ -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.
+13 -10
View File
@@ -1,18 +1,21 @@
FROM python:3-alpine FROM python:3.12-slim
LABEL maintainer='<author>' LABEL maintainer='<author>'
LABEL version='0.0.0-dev.0-build.0' LABEL version='0.0.0-dev.0-build.0'
ADD . /code ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /code WORKDIR /code
RUN \
apk add --no-cache libc-dev libffi-dev gcc && \ COPY requirements.txt /code/
pip install -r requirements.txt --no-cache-dir && \ RUN pip install -r requirements.txt --no-cache-dir
apk del gcc libc-dev libffi-dev && \
addgroup webssh && \ COPY . /code
adduser -Ss /bin/false -g webssh webssh && \ RUN useradd --system --create-home --shell /usr/sbin/nologin webssh && \
chown -R webssh:webssh /code mkdir -p /data && \
chown -R webssh:webssh /code /data
EXPOSE 8888/tcp EXPOSE 8888/tcp
USER webssh USER webssh
CMD ["python", "run.py"] CMD ["python", "run.py", "--address=0.0.0.0", "--port=8888", "--data-dir=/data"]
+31 -3
View File
@@ -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` 1. Install this app, run command `pip install webssh`
2. Start a webserver, run command `wssh` 2. Start a webserver, run command `wssh`
3. Open your browser, navigate to `127.0.0.1:8888` 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 ### Server options
@@ -66,6 +88,9 @@ wssh --logging=debug
# log to file # log to file
wssh --log-file-prefix=main.log wssh --log-file-prefix=main.log
# data directory for login and saved connection data
wssh --data-dir='/path/to/webssh-data'
# more options # more options
wssh --help wssh --help
``` ```
@@ -153,14 +178,17 @@ http://localhost:8888/?term=xterm-256color
Start up the app Start up the app
``` ```
docker-compose up docker compose up -d
``` ```
Tear down the app 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 ### Tests
Requirements Requirements
+31 -3
View File
@@ -49,7 +49,29 @@ Quickstart
1. Install this app, run command ``pip install webssh`` 1. Install this app, run command ``pip install webssh``
2. Start a webserver, run command ``wssh`` 2. Start a webserver, run command ``wssh``
3. Open your browser, navigate to ``127.0.0.1:8888`` 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 Server options
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@@ -71,6 +93,9 @@ Server options
# log to file # log to file
wssh --log-file-prefix=main.log wssh --log-file-prefix=main.log
# data directory for login and saved connection data
wssh --data-dir='/path/to/webssh-data'
# more options # more options
wssh --help wssh --help
@@ -162,13 +187,16 @@ Start up the app
:: ::
docker-compose up docker compose up -d
Tear down the app 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 Tests
~~~~~ ~~~~~
+7 -1
View File
@@ -1,6 +1,12 @@
version: '3' version: '3'
services: services:
web: webssh:
build: . build: .
restart: unless-stopped
ports: ports:
- "8888:8888" - "8888:8888"
volumes:
- webssh-data:/data
volumes:
webssh-data:
+2
View File
@@ -99,6 +99,7 @@ class TestAppBasic(TestAppBase):
options.syshostfile = '' options.syshostfile = ''
options.tdstream = '' options.tdstream = ''
options.delay = 0.1 options.delay = 0.1
options.auth = False
app = make_app(make_handlers(loop, options), get_app_settings(options)) app = make_app(make_handlers(loop, options), get_app_settings(options))
return app return app
@@ -536,6 +537,7 @@ class OtherTestBase(TestAppBase):
options.tdstream = self.tdstream options.tdstream = self.tdstream
options.maxconn = self.maxconn options.maxconn = self.maxconn
options.origin = self.origin options.origin = self.origin
options.auth = False
app = make_app(make_handlers(loop, options), get_app_settings(options)) app = make_app(make_handlers(loop, options), get_app_settings(options))
return app return app
+36
View File
@@ -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'))
+64
View File
@@ -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': []})
+46
View File
@@ -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'))
+153
View File
@@ -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
+181 -5
View File
@@ -265,6 +265,34 @@ class MixinHandler(object):
port = '' if port == 443 else ':%s' % port port = '' if port == 443 else ':%s' % port
return 'https://{}{}{}'.format(hostname, port, uri) 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): def set_default_headers(self):
for header in self.custom_headers.items(): for header in self.custom_headers.items():
self.set_header(*header) self.set_header(*header)
@@ -303,6 +331,118 @@ class MixinHandler(object):
return (ip, port) 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): class NotFoundHandler(MixinHandler, tornado.web.ErrorHandler):
def initialize(self): def initialize(self):
@@ -395,6 +535,7 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
privatekey, filename = self.get_privatekey() privatekey, filename = self.get_privatekey()
passphrase = self.get_argument('passphrase', u'') passphrase = self.get_argument('passphrase', u'')
totp = self.get_argument('totp', u'') totp = self.get_argument('totp', u'')
term = self.get_argument('term', u'') or u'xterm'
if isinstance(self.policy, paramiko.RejectPolicy): if isinstance(self.policy, paramiko.RejectPolicy):
self.lookup_hostname(hostname, port) self.lookup_hostname(hostname, port)
@@ -405,8 +546,15 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
pkey = None pkey = None
self.ssh_client.totp = totp self.ssh_client.totp = totp
args = (hostname, port, username, password, pkey) args = (hostname, port, username, password, pkey, term)
logging.debug(args) self.connection_info = {
'hostname': hostname,
'port': port,
'username': username,
'term': term,
'auth_type': 'privatekey' if privatekey else 'password'
}
logging.debug(args[:5])
return args return args
@@ -449,10 +597,12 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
def ssh_connect(self, args): def ssh_connect(self, args):
ssh = self.ssh_client ssh = self.ssh_client
dst_addr = args[:2] dst_addr = args[:2]
connect_args = args[:5]
term = args[5]
logging.info('Connecting to {}:{}'.format(*dst_addr)) logging.info('Connecting to {}:{}'.format(*dst_addr))
try: try:
ssh.connect(*args, timeout=options.timeout) ssh.connect(*connect_args, timeout=options.timeout)
except socket.error: except socket.error:
raise ValueError('Unable to connect to {}:{}'.format(*dst_addr)) raise ValueError('Unable to connect to {}:{}'.format(*dst_addr))
except paramiko.BadAuthenticationType: except paramiko.BadAuthenticationType:
@@ -462,7 +612,6 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
except paramiko.BadHostKeyException: except paramiko.BadHostKeyException:
raise ValueError('Bad host key.') raise ValueError('Bad host key.')
term = self.get_argument('term', u'') or u'xterm'
chan = ssh.invoke_shell(term=term) chan = ssh.invoke_shell(term=term)
chan.setblocking(0) chan.setblocking(0)
worker = Worker(self.loop, ssh, chan, dst_addr) 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': if not event_origin and self.origin_policy != 'same':
self.set_header('Access-Control-Allow-Origin', origin) self.set_header('Access-Control-Allow-Origin', origin)
@tornado.web.authenticated
def head(self): def head(self):
pass pass
@tornado.web.authenticated
def get(self): 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.gen.coroutine
@tornado.web.authenticated
def post(self): def post(self):
if self.debug and self.get_argument('error', u''): if self.debug and self.get_argument('error', u''):
# for testing purpose only # for testing purpose only
@@ -522,9 +680,23 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
workers[worker.id] = worker workers[worker.id] = worker
self.loop.call_later(options.delay, recycle_worker, worker) self.loop.call_later(options.delay, recycle_worker, worker)
self.result.update(id=worker.id, encoding=worker.encoding) self.result.update(id=worker.id, encoding=worker.encoding)
profile = self.save_connection()
if profile:
self.result.update(profile=profile)
self.write(self.result) 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): class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
@@ -533,6 +705,10 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
self.worker_ref = None self.worker_ref = None
def open(self): def open(self):
if not self.current_user:
self.close(reason='Login required.')
return
self.src_addr = self.get_client_addr() self.src_addr = self.get_client_addr()
logging.info('Connected from {}:{}'.format(*self.src_addr)) logging.info('Connected from {}:{}'.format(*self.src_addr))
+8 -1
View File
@@ -4,7 +4,10 @@ import tornado.ioloop
from tornado.options import options from tornado.options import options
from webssh import handler 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 ( from webssh.settings import (
get_app_settings, get_host_keys_settings, get_policy_setting, get_app_settings, get_host_keys_settings, get_policy_setting,
get_ssl_context, get_server_settings, check_encoding_setting get_ssl_context, get_server_settings, check_encoding_setting
@@ -16,6 +19,10 @@ def make_handlers(loop, options):
policy = get_policy_setting(options, host_keys_settings) policy = get_policy_setting(options, host_keys_settings)
handlers = [ handlers = [
(r'/login', LoginHandler),
(r'/logout', LogoutHandler),
(r'/connections', ConnectionsHandler),
(r'/connections/([a-f0-9]{24})', ConnectionHandler),
(r'/', IndexHandler, dict(loop=loop, policy=policy, (r'/', IndexHandler, dict(loop=loop, policy=policy,
host_keys_settings=host_keys_settings)), host_keys_settings=host_keys_settings)),
(r'/ws', WsockHandler, dict(loop=loop)) (r'/ws', WsockHandler, dict(loop=loop))
+78
View File
@@ -1,12 +1,15 @@
import logging import logging
import os.path import os.path
import secrets
import ssl import ssl
import sys import sys
from tornado.options import define from tornado.options import define
from webssh.auth import AuthManager
from webssh.policy import ( from webssh.policy import (
load_host_keys, get_policy_class, check_policy_setting load_host_keys, get_policy_class, check_policy_setting
) )
from webssh.storage import ConnectionStore
from webssh.utils import ( from webssh.utils import (
to_ip_address, parse_origin_from_url, is_valid_encoding to_ip_address, parse_origin_from_url, is_valid_encoding
) )
@@ -51,6 +54,24 @@ define('font', default='', help='custom font filename')
define('encoding', default='', define('encoding', default='',
help='''The default character encoding of ssh servers. help='''The default character encoding of ssh servers.
Example: --encoding='utf-8' to solve the problem with some switches&routers''') 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', define('version', type=bool, help='Show version information',
callback=print_version) callback=print_version)
@@ -74,12 +95,17 @@ class Font(object):
def get_app_settings(options): def get_app_settings(options):
auth_manager = get_auth_manager(options)
settings = dict( settings = dict(
template_path=os.path.join(base_dir, 'webssh', 'templates'), template_path=os.path.join(base_dir, 'webssh', 'templates'),
static_path=os.path.join(base_dir, 'webssh', 'static'), static_path=os.path.join(base_dir, 'webssh', 'static'),
websocket_ping_interval=options.wpintvl, websocket_ping_interval=options.wpintvl,
debug=options.debug, debug=options.debug,
xsrf_cookies=options.xsrf, 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( font=Font(
get_font_filename(options.font, get_font_filename(options.font,
os.path.join(base_dir, *font_dirs)), os.path.join(base_dir, *font_dirs)),
@@ -87,6 +113,8 @@ def get_app_settings(options):
), ),
origin_policy=get_origin_setting(options) origin_policy=get_origin_setting(options)
) )
if auth_manager:
settings['cookie_secret'] = get_cookie_secret(options)
return settings return settings
@@ -196,3 +224,53 @@ def get_font_filename(font, font_dir):
def check_encoding_setting(encoding): def check_encoding_setting(encoding):
if encoding and not is_valid_encoding(encoding): if encoding and not is_valid_encoding(encoding):
raise ValueError('Unknown character encoding {!r}.'.format(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
+388
View File
@@ -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%;
}
}
+218 -25
View File
@@ -37,9 +37,12 @@ var wssh = {};
jQuery(function($){ jQuery(function($){
var status = $('#status'), var status = $('#status'),
button = $('.btn-primary'), button = $('#connect .btn-primary'),
form_container = $('.form-container'), form_container = $('.form-container'),
waiter = $('#waiter'), waiter = $('#waiter'),
saved_connections_select = $('#saved-connections'),
load_connection_button = $('#load-connection'),
delete_connection_button = $('#delete-connection'),
term_type = $('#term'), term_type = $('#term'),
style = {}, style = {},
default_title = 'WebSSH', default_title = 'WebSSH',
@@ -52,13 +55,15 @@ jQuery(function($){
CONNECTING = 1, CONNECTING = 1,
CONNECTED = 2, CONNECTED = 2,
state = DISCONNECTED, state = DISCONNECTED,
messages = {1: 'This client is connecting ...', 2: 'This client is already connnected.'}, messages = {1: '正在连接,请稍候 ...', 2: '当前客户端已经连接。'},
key_max_size = 16384, key_max_size = 16384,
fields = ['hostname', 'port', 'username'], fields = ['hostname', 'port', 'username'],
form_keys = fields.concat(['password', 'totp']), form_keys = fields.concat(['password', 'totp']),
opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'], opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'],
url_form_data = {}, url_form_data = {},
url_opts_data = {}, url_opts_data = {},
saved_connections = [],
saved_connection_map = {},
validated_form_data, validated_form_data,
event_origin, event_origin,
hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/; hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/;
@@ -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(
$('<option>').val('').text('未选择保存的连接')
);
for (i = 0; i < saved_connections.length; i++) {
profile = saved_connections[i];
option = $('<option>').val(profile.id).text(profile.title);
saved_connections_select.append(option);
}
if (selected) {
saved_connections_select.val(selected);
}
}
function upsert_saved_connection(profile) {
var i, found = false;
if (!profile || !profile.id) {
return;
}
for (i = 0; i < saved_connections.length; i++) {
if (saved_connections[i].id === profile.id) {
saved_connections[i] = profile;
found = true;
break;
}
}
if (!found) {
saved_connections.unshift(profile);
}
saved_connection_map[profile.id] = profile;
render_saved_connections(profile.id);
}
function load_saved_connections() {
$.ajax({
url: 'connections',
type: 'get',
success: function(data) {
set_saved_connections(data.connections);
},
error: function(resp) {
if (resp.status !== 404) {
console.error(resp.status + ': ' + resp.statusText);
}
}
});
}
function selected_connection() {
var profile_id = saved_connections_select.val();
return saved_connection_map[profile_id];
}
function apply_saved_connection() {
var profile = selected_connection();
if (!profile) {
return;
}
$('#hostname').val(profile.hostname);
$('#port').val(profile.port);
$('#username').val(profile.username);
$('#password').val('');
$('#privatekey').val('');
$('#passphrase').val('');
$('#totp').val('');
term_type.val(profile.term || 'xterm-256color');
status.text('');
}
function delete_saved_connection() {
var profile = selected_connection();
if (!profile) {
return;
}
$.ajax({
url: 'connections/' + profile.id,
type: 'delete',
headers: {'X-XSRFToken': get_xsrf_token()},
success: function() {
set_saved_connections(
saved_connections.filter(function(item) {
return item.id !== profile.id;
})
);
},
error: function(resp) {
log_status(resp.status + ': ' + resp.statusText);
}
});
}
function populate_form(data) { function populate_form(data) {
var names = form_keys.concat(['passphrase']), var names = form_keys.concat(['passphrase']),
i, name; i, name;
@@ -324,6 +456,7 @@ jQuery(function($){
function log_status(text, to_populate) { function log_status(text, to_populate) {
text = translate_status(text);
console.log(text); console.log(text);
status.text(text); status.text(text);
@@ -342,6 +475,49 @@ jQuery(function($){
} }
function translate_status(text) {
if (!text) {
return text;
}
var replacements = [
['Authentication failed.', '认证失败。'],
['Bad authentication type.', '认证方式不被服务器支持。'],
['Bad host key.', '主机密钥校验失败。'],
['Too many live connections.', '当前客户端连接数过多。'],
['Cross origin operation is not allowed.', '不允许跨来源操作。'],
['Missing value hostname', '请输入主机地址。'],
['Missing value username', '请输入 SSH 用户名。'],
['Missing argument hostname', '缺少主机地址。'],
['Missing argument username', '缺少 SSH 用户名。'],
['Need a verification code for 2fa.', '需要输入动态验证码。']
];
var i, item;
for (i = 0; i < replacements.length; i++) {
item = replacements[i];
if (text === item[0]) {
return item[1];
}
}
if (text.indexOf('Unable to connect to ') === 0) {
return '无法连接到 ' + text.slice('Unable to connect to '.length);
}
if (text.indexOf('Invalid hostname: ') === 0) {
return '主机地址无效:' + text.slice('Invalid hostname: '.length);
}
if (text.indexOf('Invalid port: ') === 0) {
return '端口无效:' + text.slice('Invalid port: '.length);
}
if (text.indexOf('Invalid private key: ') === 0) {
return '私钥文件无效:' + text.slice('Invalid private key: '.length);
}
return text;
}
function ajax_complete_callback(resp) { function ajax_complete_callback(resp) {
button.prop('disabled', false); button.prop('disabled', false);
@@ -358,6 +534,10 @@ jQuery(function($){
return; return;
} }
if (msg.profile) {
upsert_saved_connection(msg.profile);
}
var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'), var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'),
join = (ws_url[ws_url.length-1] === '/' ? '' : '/'), join = (ws_url[ws_url.length-1] === '/' ? '' : '/'),
url = ws_url + join + 'ws?id=' + msg.id, url = ws_url + join + 'ws?id=' + msg.id,
@@ -368,9 +548,9 @@ jQuery(function($){
termOptions = { termOptions = {
cursorBlink: true, cursorBlink: true,
theme: { theme: {
background: url_opts_data.bgcolor || 'black', background: url_opts_data.bgcolor || '#0f1720',
foreground: url_opts_data.fontcolor || 'white', foreground: url_opts_data.fontcolor || '#d9f1ee',
cursor: url_opts_data.cursor || url_opts_data.fontcolor || 'white' cursor: url_opts_data.cursor || url_opts_data.fontcolor || '#55c7ba'
} }
}; };
@@ -388,10 +568,10 @@ jQuery(function($){
console.log(url); console.log(url);
if (!msg.encoding) { if (!msg.encoding) {
console.log('Unable to detect the default encoding of your server'); console.log('无法检测服务器默认编码');
msg.encoding = encoding; msg.encoding = encoding;
} else { } else {
console.log('The deault encoding of your server is ' + msg.encoding); console.log('服务器默认编码是 ' + msg.encoding);
} }
function term_write(text) { function term_write(text) {
@@ -407,21 +587,21 @@ jQuery(function($){
function set_encoding(new_encoding) { function set_encoding(new_encoding) {
// for console use // for console use
if (!new_encoding) { if (!new_encoding) {
console.log('An encoding is required'); console.log('请提供编码名称');
return; return;
} }
if (!window.TextDecoder) { if (!window.TextDecoder) {
decoder = new_encoding; decoder = new_encoding;
encoding = decoder; encoding = decoder;
console.log('Set encoding to ' + encoding); console.log('已将编码设置为 ' + encoding);
} else { } else {
try { try {
decoder = new window.TextDecoder(new_encoding); decoder = new window.TextDecoder(new_encoding);
encoding = decoder.encoding; encoding = decoder.encoding;
console.log('Set encoding to ' + encoding); console.log('已将编码设置为 ' + encoding);
} catch (RangeError) { } catch (RangeError) {
console.log('Unknown encoding ' + new_encoding); console.log('未知编码 ' + new_encoding);
return false; return false;
} }
} }
@@ -441,18 +621,18 @@ jQuery(function($){
wssh.geometry = function() { wssh.geometry = function() {
// for console use // for console use
var geometry = current_geometry(term); var geometry = current_geometry(term);
console.log('Current window geometry: ' + JSON.stringify(geometry)); console.log('当前终端尺寸:' + JSON.stringify(geometry));
}; };
wssh.send = function(data) { wssh.send = function(data) {
// for console use // for console use
if (!sock) { if (!sock) {
console.log('Websocket was already closed'); console.log('WebSocket 已关闭');
return; return;
} }
if (typeof data !== 'string') { if (typeof data !== 'string') {
console.log('Only string is allowed'); console.log('只允许发送字符串');
return; return;
} }
@@ -468,7 +648,7 @@ jQuery(function($){
wssh.reset_encoding = function() { wssh.reset_encoding = function() {
// for console use // for console use
if (encoding === msg.encoding) { if (encoding === msg.encoding) {
console.log('Already reset to ' + msg.encoding); console.log('已经恢复为 ' + msg.encoding);
} else { } else {
set_encoding(msg.encoding); set_encoding(msg.encoding);
} }
@@ -477,7 +657,7 @@ jQuery(function($){
wssh.resize = function(cols, rows) { wssh.resize = function(cols, rows) {
// for console use // for console use
if (term === undefined) { if (term === undefined) {
console.log('Terminal was already destroryed'); console.log('终端已经关闭');
return; return;
} }
@@ -491,7 +671,7 @@ jQuery(function($){
} }
if (!valid_args) { if (!valid_args) {
console.log('Unable to resize terminal to geometry: ' + format_geometry(cols, rows)); console.log('无法调整终端尺寸为:' + format_geometry(cols, rows));
} else { } else {
term.on_resize(cols, rows); term.on_resize(cols, rows);
} }
@@ -515,7 +695,7 @@ jQuery(function($){
term.on_resize = function(cols, rows) { term.on_resize = function(cols, rows) {
if (cols !== this.cols || rows !== this.rows) { if (cols !== this.cols || rows !== this.rows) {
console.log('Resizing terminal to geometry: ' + format_geometry(cols, rows)); console.log('正在调整终端尺寸为:' + format_geometry(cols, rows));
this.resize(cols, rows); this.resize(cols, rows);
sock.send(JSON.stringify({'resize': [cols, rows]})); sock.send(JSON.stringify({'resize': [cols, rows]}));
} }
@@ -611,10 +791,10 @@ jQuery(function($){
errors = [], size; errors = [], size;
if (!hostname) { if (!hostname) {
errors.push('Value of hostname is required.'); errors.push('请输入主机地址。');
} else { } else {
if (!hostname_tester.test(hostname)) { if (!hostname_tester.test(hostname)) {
errors.push('Invalid hostname: ' + hostname); errors.push('主机地址无效:' + hostname);
} }
} }
@@ -622,18 +802,18 @@ jQuery(function($){
port = 22; port = 22;
} else { } else {
if (!(port > 0 && port <= 65535)) { if (!(port > 0 && port <= 65535)) {
errors.push('Invalid port: ' + port); errors.push('端口无效:' + port);
} }
} }
if (!username) { if (!username) {
errors.push('Value of username is required.'); errors.push('请输入 SSH 用户名。');
} }
if (pk) { if (pk) {
size = pk.size || pk.length; size = pk.size || pk.length;
if (size > key_max_size) { if (size > key_max_size) {
errors.push('Invalid private key: ' + pk.name || ''); errors.push('私钥文件无效:' + pk.name || '');
} }
} }
@@ -704,7 +884,7 @@ jQuery(function($){
if (pk && pk.size && !debug) { if (pk && pk.size && !debug) {
read_file_as_text(pk, function(text) { read_file_as_text(pk, function(text) {
if (text === undefined) { if (text === undefined) {
log_status('Invalid private key: ' + pk.name); log_status('私钥文件无效:' + pk.name);
} else { } else {
ajax_post(); ajax_post();
} }
@@ -795,6 +975,18 @@ jQuery(function($){
connect(); connect();
}); });
load_connection_button.click(function() {
apply_saved_connection();
});
saved_connections_select.change(function() {
apply_saved_connection();
});
delete_connection_button.click(function() {
delete_saved_connection();
});
function cross_origin_connect(event) function cross_origin_connect(event)
{ {
@@ -845,12 +1037,13 @@ jQuery(function($){
} }
if (url_form_data.password === null) { if (url_form_data.password === null) {
log_status('Password via url must be encoded in base64.'); log_status('URL 中的密码必须使用 base64 编码。');
} else { } else {
if (get_object_length(url_form_data)) { if (get_object_length(url_form_data)) {
waiter.show(); waiter.show();
connect(url_form_data); connect(url_form_data);
} else { } else {
load_saved_connections();
restore_items(fields); restore_items(fields);
form_container.show(); form_container.show();
} }
+132
View File
@@ -0,0 +1,132 @@
import hashlib
import json
import os
import threading
import time
DEFAULT_PORT = 22
class ConnectionStore(object):
def __init__(self, filename):
self.filename = filename
self.lock = threading.Lock()
def list(self, owner):
data = self._read()
profiles = [
profile for profile in data.get('connections', [])
if profile.get('owner') == owner
]
return sorted(
profiles,
key=lambda item: item.get('updated_at', 0),
reverse=True
)
def upsert(self, owner, connection):
profile = self._normalize(owner, connection)
with self.lock:
data = self._read()
profiles = data.setdefault('connections', [])
existing = None
for item in profiles:
if item.get('id') == profile['id']:
existing = item
break
now = int(time.time())
profile['updated_at'] = now
profile['last_connected_at'] = now
if existing:
profile['created_at'] = existing.get('created_at', now)
existing.clear()
existing.update(profile)
else:
profile['created_at'] = now
profiles.append(profile)
self._write(data)
return profile
def delete(self, owner, profile_id):
with self.lock:
data = self._read()
profiles = data.get('connections', [])
filtered = [
item for item in profiles
if not (
item.get('owner') == owner and item.get('id') == profile_id
)
]
deleted = len(filtered) != len(profiles)
if deleted:
data['connections'] = filtered
self._write(data)
return deleted
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()
port = connection.get('port') or DEFAULT_PORT
port = int(port)
title = '{}@{}:{}'.format(username, hostname, port)
auth_type = connection.get('auth_type') or 'password'
profile_id = self._make_id(owner, hostname, port, username)
return {
'id': profile_id,
'owner': owner,
'title': title,
'hostname': hostname,
'port': port,
'username': username,
'term': term,
'auth_type': auth_type
}
def _make_id(self, owner, hostname, port, username):
raw = '\0'.join([owner, hostname, str(port), username])
return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:24]
def _read(self):
if not self.filename or not os.path.isfile(self.filename):
return {'connections': []}
with open(self.filename, encoding='utf-8') as f:
try:
data = json.load(f)
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
if not isinstance(data.get('connections'), list):
data['connections'] = []
return data
def _write(self, data):
dirname = os.path.dirname(self.filename)
if dirname and not os.path.isdir(dirname):
os.makedirs(dirname)
tmp = self.filename + '.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.filename)
_chmod_private(self.filename)
def _chmod_private(path):
try:
os.chmod(path, 0o600)
except OSError:
pass
+93 -61
View File
@@ -1,30 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebSSH</title> <title>WebSSH</title>
<link href="static/img/favicon.png" rel="icon" type="image/png"> <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/bootstrap.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/xterm.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/fullscreen.min.css" rel="stylesheet" type="text/css"/>
<style> <link href="static/css/app.css" rel="stylesheet" type="text/css"/>
.row {
margin-top: 15px;
margin-bottom: 10px;
}
.container {
margin-top: 20px;
}
.btn {
margin-top: 15px;
}
.btn-danger {
margin-left: 5px;
}
{% if font.family %} {% if font.family %}
<style>
@font-face { @font-face {
font-family: '{{ font.family }}'; font-family: '{{ font.family }}';
src: url('{{ font.url }}'); src: url('{{ font.url }}');
@@ -33,63 +19,109 @@
body { body {
font-family: '{{ font.family }}'; font-family: '{{ font.family }}';
} }
{% end %}
</style> </style>
{% end %}
</head> </head>
<body> <body class="app-page">
<div id="waiter" style="display: none"> Connecting ... </div> <div id="waiter" style="display: none">
<div class="waiter-card">
<span class="waiter-spinner" aria-hidden="true"></span>
<span>正在连接 ...</span>
</div>
</div>
<div class="container form-container" style="display: none"> <main class="app-shell form-container" style="display: none">
<form id="connect" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}> <section class="app-panel">
<div class="row"> <header class="topbar">
<div class="col"> <div class="brand-block">
<label for="Hostname">Hostname</label> <span class="brand-kicker">WEBSSH</span>
<input class="form-control" type="text" id="hostname" name="hostname" value="" required> <h1>连接控制台</h1>
</div> </div>
<div class="col"> {% if auth_enabled %}
<label for="Port">Port</label> <div class="session-meta">
<input class="form-control" type="number" id="port" name="port" placeholder="22" value="" min=1 max=65535> <span class="session-user">{{ user }}</span>
<a href="/logout">退出登录</a>
</div>
{% end %}
</header>
<div class="saved-bar">
<div class="field">
<label for="saved-connections">已保存连接</label>
<select class="form-control" id="saved-connections">
<option value="">未选择保存的连接</option>
</select>
</div>
<div class="saved-actions">
<button type="button" class="btn btn-secondary" id="load-connection">
载入
</button>
<button type="button" class="btn btn-outline-danger"
id="delete-connection">
删除
</button>
</div> </div>
</div> </div>
<div class="row">
<div class="col"> <form id="connect" class="connection-form" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
<label for="Username">Username</label> <div class="form-grid">
<input class="form-control" type="text" id="username" name="username" value="" required> <div class="field">
<label for="hostname">主机地址</label>
<input class="form-control" type="text" id="hostname"
name="hostname" value="" required>
</div> </div>
<div class="col"> <div class="field">
<label for="Password">Password</label> <label for="port">端口</label>
<input class="form-control" type="password" id="password" name="password" value=""> <input class="form-control" type="number" id="port" name="port"
</div> placeholder="22" value="" min=1 max=65535>
</div> </div>
<div class="row"> <div class="field">
<div class="col"> <label for="username">SSH 用户名</label>
<label for="Username">Private Key</label> <input class="form-control" type="text" id="username"
<input class="form-control" type="file" id="privatekey" name="privatekey" value=""> name="username" value="" required>
</div> </div>
<div class="col"> <div class="field">
<label for="Passphrase">Passphrase</label> <label for="password">SSH 密码</label>
<input class="form-control" type="password" id="passphrase" name="passphrase" value=""> <input class="form-control" type="password" id="password"
</div> name="password" value="">
</div> </div>
<div class="row"> <div class="field">
<div class="col"> <label for="privatekey">私钥文件</label>
<label for="totp">Totp (time-based one-time password)</label> <input class="form-control" type="file" id="privatekey"
<input class="form-control" type="password" id="totp" name="totp" value=""> name="privatekey" value="">
</div> </div>
<div class="col"> <div class="field">
<label for="passphrase">私钥口令</label>
<input class="form-control" type="password" id="passphrase"
name="passphrase" value="">
</div>
<div class="field">
<label for="totp">TOTP 动态验证码</label>
<input class="form-control" type="password" id="totp"
name="totp" value="">
</div>
<div class="field save-field">
<label class="save-toggle" for="save">
<input type="checkbox" id="save" name="save" value="1" checked>
<span>保存连接信息</span>
</label>
</div> </div>
</div> </div>
<input type="hidden" id="term" name="term" value="xterm-256color"> <input type="hidden" id="term" name="term" value="xterm-256color">
{% module xsrf_form_html() %} {% module xsrf_form_html() %}
<button type="submit" class="btn btn-primary">Connect</button> <div class="action-row">
<button type="reset" class="btn btn-danger">Reset</button> <button type="submit" class="btn btn-primary">连接</button>
<button type="reset" class="btn btn-outline-secondary">重置</button>
</div>
</form> </form>
</div> </section>
</main>
<div class="container"> <section class="terminal-stage">
<div id="status" style="color: red; white-space: pre-line;"></div> <div id="status" class="status-message"></div>
<div id="terminal"></div> <div id="terminal"></div>
</div> </section>
<script src="static/js/jquery.min.js"></script> <script src="static/js/jquery.min.js"></script>
<script src="static/js/popper.min.js"></script> <script src="static/js/popper.min.js"></script>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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"/>
</head>
<body class="login-page">
<main class="login-shell">
<section class="login-panel">
<header class="login-title">
<span class="brand-kicker">WEBSSH</span>
<h1>{% if setup %}创建管理员账号{% else %}登录{% end %}</h1>
<p>{% if setup %}初始化访问凭据{% else %}欢迎回来{% end %}</p>
</header>
{% if error %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% end %}
<form action="/login" method="post">
<input type="hidden" name="next" value="{{ next_url }}">
{% module xsrf_form_html() %}
<div class="form-group">
<label for="username">用户名</label>
<input class="form-control" id="username" name="username"
type="text" value="{{ username }}" autocomplete="username"
required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input class="form-control" id="password" name="password"
type="password"
autocomplete="{% if setup %}new-password{% else %}current-password{% end %}"
required>
</div>
{% if setup %}
<div class="form-group">
<label for="confirm">确认密码</label>
<input class="form-control" id="confirm" name="confirm"
type="password" autocomplete="new-password" required>
</div>
{% end %}
<button class="btn btn-primary btn-block" type="submit">
{% if setup %}创建管理员账号{% else %}登录{% end %}
</button>
</form>
</section>
</main>
</body>
</html>