Compare commits

...

21 Commits

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