Prepare WebSSH for self-hosted deployment
This commit is contained in:
@@ -1 +1,12 @@
|
||||
.git
|
||||
.github
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.coverage
|
||||
build
|
||||
dist
|
||||
*.egg-info
|
||||
.webssh-data
|
||||
.webssh-server.log*
|
||||
|
||||
+10
-63
@@ -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
|
||||
|
||||
@@ -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
@@ -1,18 +1,21 @@
|
||||
FROM python:3-alpine
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer='<author>'
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-3
@@ -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
|
||||
~~~~~
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
webssh:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- webssh-data:/data
|
||||
|
||||
volumes:
|
||||
webssh-data:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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': []})
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
|
||||
+8
-1
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
$('<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) {
|
||||
var names = form_keys.concat(['passphrase']),
|
||||
i, name;
|
||||
@@ -324,6 +456,7 @@ jQuery(function($){
|
||||
|
||||
|
||||
function log_status(text, to_populate) {
|
||||
text = translate_status(text);
|
||||
console.log(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) {
|
||||
button.prop('disabled', false);
|
||||
|
||||
@@ -358,6 +534,10 @@ jQuery(function($){
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.profile) {
|
||||
upsert_saved_connection(msg.profile);
|
||||
}
|
||||
|
||||
var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'),
|
||||
join = (ws_url[ws_url.length-1] === '/' ? '' : '/'),
|
||||
url = ws_url + join + 'ws?id=' + msg.id,
|
||||
@@ -368,9 +548,9 @@ jQuery(function($){
|
||||
termOptions = {
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: url_opts_data.bgcolor || 'black',
|
||||
foreground: url_opts_data.fontcolor || 'white',
|
||||
cursor: url_opts_data.cursor || url_opts_data.fontcolor || 'white'
|
||||
background: url_opts_data.bgcolor || '#0f1720',
|
||||
foreground: url_opts_data.fontcolor || '#d9f1ee',
|
||||
cursor: url_opts_data.cursor || url_opts_data.fontcolor || '#55c7ba'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -388,10 +568,10 @@ jQuery(function($){
|
||||
|
||||
console.log(url);
|
||||
if (!msg.encoding) {
|
||||
console.log('Unable to detect the default encoding of your server');
|
||||
console.log('无法检测服务器默认编码');
|
||||
msg.encoding = encoding;
|
||||
} else {
|
||||
console.log('The deault encoding of your server is ' + msg.encoding);
|
||||
console.log('服务器默认编码是 ' + msg.encoding);
|
||||
}
|
||||
|
||||
function term_write(text) {
|
||||
@@ -407,21 +587,21 @@ jQuery(function($){
|
||||
function set_encoding(new_encoding) {
|
||||
// for console use
|
||||
if (!new_encoding) {
|
||||
console.log('An encoding is required');
|
||||
console.log('请提供编码名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.TextDecoder) {
|
||||
decoder = new_encoding;
|
||||
encoding = decoder;
|
||||
console.log('Set encoding to ' + encoding);
|
||||
console.log('已将编码设置为 ' + encoding);
|
||||
} else {
|
||||
try {
|
||||
decoder = new window.TextDecoder(new_encoding);
|
||||
encoding = decoder.encoding;
|
||||
console.log('Set encoding to ' + encoding);
|
||||
console.log('已将编码设置为 ' + encoding);
|
||||
} catch (RangeError) {
|
||||
console.log('Unknown encoding ' + new_encoding);
|
||||
console.log('未知编码 ' + new_encoding);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -441,18 +621,18 @@ jQuery(function($){
|
||||
wssh.geometry = function() {
|
||||
// for console use
|
||||
var geometry = current_geometry(term);
|
||||
console.log('Current window geometry: ' + JSON.stringify(geometry));
|
||||
console.log('当前终端尺寸:' + JSON.stringify(geometry));
|
||||
};
|
||||
|
||||
wssh.send = function(data) {
|
||||
// for console use
|
||||
if (!sock) {
|
||||
console.log('Websocket was already closed');
|
||||
console.log('WebSocket 已关闭');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data !== 'string') {
|
||||
console.log('Only string is allowed');
|
||||
console.log('只允许发送字符串');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -468,7 +648,7 @@ jQuery(function($){
|
||||
wssh.reset_encoding = function() {
|
||||
// for console use
|
||||
if (encoding === msg.encoding) {
|
||||
console.log('Already reset to ' + msg.encoding);
|
||||
console.log('已经恢复为 ' + msg.encoding);
|
||||
} else {
|
||||
set_encoding(msg.encoding);
|
||||
}
|
||||
@@ -477,7 +657,7 @@ jQuery(function($){
|
||||
wssh.resize = function(cols, rows) {
|
||||
// for console use
|
||||
if (term === undefined) {
|
||||
console.log('Terminal was already destroryed');
|
||||
console.log('终端已经关闭');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -491,7 +671,7 @@ jQuery(function($){
|
||||
}
|
||||
|
||||
if (!valid_args) {
|
||||
console.log('Unable to resize terminal to geometry: ' + format_geometry(cols, rows));
|
||||
console.log('无法调整终端尺寸为:' + format_geometry(cols, rows));
|
||||
} else {
|
||||
term.on_resize(cols, rows);
|
||||
}
|
||||
@@ -515,7 +695,7 @@ jQuery(function($){
|
||||
|
||||
term.on_resize = function(cols, 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);
|
||||
sock.send(JSON.stringify({'resize': [cols, rows]}));
|
||||
}
|
||||
@@ -611,10 +791,10 @@ jQuery(function($){
|
||||
errors = [], size;
|
||||
|
||||
if (!hostname) {
|
||||
errors.push('Value of hostname is required.');
|
||||
errors.push('请输入主机地址。');
|
||||
} else {
|
||||
if (!hostname_tester.test(hostname)) {
|
||||
errors.push('Invalid hostname: ' + hostname);
|
||||
errors.push('主机地址无效:' + hostname);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,18 +802,18 @@ jQuery(function($){
|
||||
port = 22;
|
||||
} else {
|
||||
if (!(port > 0 && port <= 65535)) {
|
||||
errors.push('Invalid port: ' + port);
|
||||
errors.push('端口无效:' + port);
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
errors.push('Value of username is required.');
|
||||
errors.push('请输入 SSH 用户名。');
|
||||
}
|
||||
|
||||
if (pk) {
|
||||
size = pk.size || pk.length;
|
||||
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) {
|
||||
read_file_as_text(pk, function(text) {
|
||||
if (text === undefined) {
|
||||
log_status('Invalid private key: ' + pk.name);
|
||||
log_status('私钥文件无效:' + pk.name);
|
||||
} else {
|
||||
ajax_post();
|
||||
}
|
||||
@@ -795,6 +975,18 @@ jQuery(function($){
|
||||
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)
|
||||
{
|
||||
@@ -845,12 +1037,13 @@ jQuery(function($){
|
||||
}
|
||||
|
||||
if (url_form_data.password === null) {
|
||||
log_status('Password via url must be encoded in base64.');
|
||||
log_status('URL 中的密码必须使用 base64 编码。');
|
||||
} else {
|
||||
if (get_object_length(url_form_data)) {
|
||||
waiter.show();
|
||||
connect(url_form_data);
|
||||
} else {
|
||||
load_saved_connections();
|
||||
restore_items(fields);
|
||||
form_container.show();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+94
-62
@@ -1,30 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title> WebSSH </title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebSSH</title>
|
||||
<link href="static/img/favicon.png" rel="icon" type="image/png">
|
||||
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/xterm.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/fullscreen.min.css" rel="stylesheet" type="text/css"/>
|
||||
<style>
|
||||
.row {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
margin-left: 5px;
|
||||
}
|
||||
<link href="static/css/app.css" rel="stylesheet" type="text/css"/>
|
||||
{% if font.family %}
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: '{{ font.family }}';
|
||||
src: url('{{ font.url }}');
|
||||
@@ -33,63 +19,109 @@
|
||||
body {
|
||||
font-family: '{{ font.family }}';
|
||||
}
|
||||
{% end %}
|
||||
</style>
|
||||
{% end %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="waiter" style="display: none"> Connecting ... </div>
|
||||
<body class="app-page">
|
||||
<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">
|
||||
<form id="connect" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="Hostname">Hostname</label>
|
||||
<input class="form-control" type="text" id="hostname" name="hostname" value="" required>
|
||||
<main class="app-shell form-container" style="display: none">
|
||||
<section class="app-panel">
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-kicker">WEBSSH</span>
|
||||
<h1>连接控制台</h1>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="Port">Port</label>
|
||||
<input class="form-control" type="number" id="port" name="port" placeholder="22" value="" min=1 max=65535>
|
||||
{% if auth_enabled %}
|
||||
<div class="session-meta">
|
||||
<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 class="row">
|
||||
<div class="col">
|
||||
<label for="Username">Username</label>
|
||||
<input class="form-control" type="text" id="username" name="username" value="" required>
|
||||
|
||||
<form id="connect" class="connection-form" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="hostname">主机地址</label>
|
||||
<input class="form-control" type="text" id="hostname"
|
||||
name="hostname" value="" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="Password">Password</label>
|
||||
<input class="form-control" type="password" id="password" name="password" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="Username">Private Key</label>
|
||||
<input class="form-control" type="file" id="privatekey" name="privatekey" value="">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="Passphrase">Passphrase</label>
|
||||
<input class="form-control" type="password" id="passphrase" name="passphrase" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="totp">Totp (time-based one-time password)</label>
|
||||
<input class="form-control" type="password" id="totp" name="totp" value="">
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="field">
|
||||
<label for="port">端口</label>
|
||||
<input class="form-control" type="number" id="port" name="port"
|
||||
placeholder="22" value="" min=1 max=65535>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">SSH 用户名</label>
|
||||
<input class="form-control" type="text" id="username"
|
||||
name="username" value="" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">SSH 密码</label>
|
||||
<input class="form-control" type="password" id="password"
|
||||
name="password" value="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="privatekey">私钥文件</label>
|
||||
<input class="form-control" type="file" id="privatekey"
|
||||
name="privatekey" value="">
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<input type="hidden" id="term" name="term" value="xterm-256color">
|
||||
{% module xsrf_form_html() %}
|
||||
<button type="submit" class="btn btn-primary">Connect</button>
|
||||
<button type="reset" class="btn btn-danger">Reset</button>
|
||||
<div class="action-row">
|
||||
<button type="submit" class="btn btn-primary">连接</button>
|
||||
<button type="reset" class="btn btn-outline-secondary">重置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="container">
|
||||
<div id="status" style="color: red; white-space: pre-line;"></div>
|
||||
<section class="terminal-stage">
|
||||
<div id="status" class="status-message"></div>
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="static/js/jquery.min.js"></script>
|
||||
<script src="static/js/popper.min.js"></script>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user