Compare commits
21 Commits
21ab5ccd23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f42cb3c6d | |||
| 6f6c554c90 | |||
| 88d3838aa7 | |||
| c013a389fe | |||
| af50427169 | |||
| e7b771f10f | |||
| f5369b7ab9 | |||
| 51b6f9ecfd | |||
| b5307baca5 | |||
| 47cb0123e6 | |||
| a7d435e59e | |||
| 845c3dece3 | |||
| aaac2afe2a | |||
| e83f233eee | |||
| 5d69dc4ac1 | |||
| 9cfd5b799a | |||
| a7a704f111 | |||
| 1cf19c7186 | |||
| a1c0ded18a | |||
| 47cfeed020 | |||
| aabdfc597f |
@@ -1 +1,12 @@
|
|||||||
.git
|
.git
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
*.egg-info
|
||||||
|
.webssh-data
|
||||||
|
.webssh-server.log*
|
||||||
|
|||||||
@@ -11,18 +11,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- run: pip install --user ruff
|
- run: pip install --user ruff
|
||||||
- run: ruff --format=github --ignore=F401 --target-version=py38 .
|
|
||||||
pytest:
|
pytest:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
# - run: pip install --upgrade pip setuptools wheel
|
- run: pip install pytest==8.1.2 pytest-cov -r requirements.txt
|
||||||
- run: pip install pytest -r requirements.txt # cov pytest-cov
|
- run: pytest --cov=webssh
|
||||||
- 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
@@ -1,65 +1,12 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
.venv/
|
||||||
|
.webssh-data/
|
||||||
|
.webssh-server*.log
|
||||||
|
/auth.json
|
||||||
|
/cookie_secret
|
||||||
|
/known_hosts
|
||||||
|
tests/data/host_keys_test.db
|
||||||
|
tests/data/sshserver.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
env/
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.coverage
|
|
||||||
.cache
|
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
nosetests.xml
|
.coverage
|
||||||
coverage.xml
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
target/
|
|
||||||
|
|
||||||
# database file
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# temporary file
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# known_hosts file
|
|
||||||
known_hosts
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -1,18 +1,25 @@
|
|||||||
FROM python:3-alpine
|
ARG PYTHON_IMAGE=python:3.12-slim
|
||||||
|
FROM ${PYTHON_IMAGE}
|
||||||
|
|
||||||
LABEL maintainer='<author>'
|
LABEL maintainer='<author>'
|
||||||
LABEL version='0.0.0-dev.0-build.0'
|
LABEL version='0.0.0-dev.0-build.0'
|
||||||
|
|
||||||
ADD . /code
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
RUN \
|
|
||||||
apk add --no-cache libc-dev libffi-dev gcc && \
|
COPY requirements.txt /code/
|
||||||
pip install -r requirements.txt --no-cache-dir && \
|
RUN pip install -r requirements.txt --no-cache-dir
|
||||||
apk del gcc libc-dev libffi-dev && \
|
|
||||||
addgroup webssh && \
|
COPY . /code
|
||||||
adduser -Ss /bin/false -g webssh webssh && \
|
RUN useradd --system --create-home --shell /usr/sbin/nologin webssh && \
|
||||||
chown -R webssh:webssh /code
|
mkdir -p /data && \
|
||||||
|
chown -R webssh:webssh /code /data
|
||||||
|
|
||||||
EXPOSE 8888/tcp
|
EXPOSE 8888/tcp
|
||||||
USER webssh
|
USER webssh
|
||||||
CMD ["python", "run.py"]
|
# 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"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
## WebSSH
|
## WebSSH
|
||||||
|
|
||||||
[](https://github.com/huashengdun/webssh/actions/workflows/python.yml)
|
[](https://github.com/huashengdun/webssh/actions/workflows/python.yml)
|
||||||
[](https://codecov.io/gh/huashengdun/webssh)
|
[](https://raw.githubusercontent.com/huashengdun/webssh/coverage-badge/coverage.svg)
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ A simple web application to be used as an ssh client to connect to your ssh serv
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
* Python 3.8+
|
* Python 3.10+
|
||||||
|
|
||||||
|
|
||||||
### Quickstart
|
### 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`
|
1. Install this app, run command `pip install webssh`
|
||||||
2. Start a webserver, run command `wssh`
|
2. Start a webserver, run command `wssh`
|
||||||
3. Open your browser, navigate to `127.0.0.1:8888`
|
3. Open your browser, navigate to `127.0.0.1:8888`
|
||||||
4. Input your data, submit the form.
|
4. Create the WebSSH administrator account on first visit
|
||||||
|
5. Input your ssh data, submit the form.
|
||||||
|
|
||||||
|
|
||||||
|
### WebSSH login and saved connections
|
||||||
|
|
||||||
|
WebSSH requires a login before opening ssh sessions. On first startup, if no
|
||||||
|
administrator password has been configured, the login page will ask you to
|
||||||
|
create one. The password is stored as a PBKDF2 hash under the data directory,
|
||||||
|
not as plain text.
|
||||||
|
|
||||||
|
You can also provide credentials with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
|
||||||
|
```
|
||||||
|
|
||||||
|
Saved connections are stored under the data directory too. WebSSH saves
|
||||||
|
hostname, port, username, 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
|
### Server options
|
||||||
@@ -66,6 +88,9 @@ wssh --logging=debug
|
|||||||
# log to file
|
# log to file
|
||||||
wssh --log-file-prefix=main.log
|
wssh --log-file-prefix=main.log
|
||||||
|
|
||||||
|
# data directory for login and saved connection data
|
||||||
|
wssh --data-dir='/path/to/webssh-data'
|
||||||
|
|
||||||
# more options
|
# more options
|
||||||
wssh --help
|
wssh --help
|
||||||
```
|
```
|
||||||
@@ -84,7 +109,8 @@ var opts = {
|
|||||||
password: 'password',
|
password: 'password',
|
||||||
privatekey: 'the private key text',
|
privatekey: 'the private key text',
|
||||||
passphrase: 'passphrase',
|
passphrase: 'passphrase',
|
||||||
totp: 'totp'
|
totp: 'totp',
|
||||||
|
directory: '/var/www'
|
||||||
};
|
};
|
||||||
wssh.connect(opts);
|
wssh.connect(opts);
|
||||||
|
|
||||||
@@ -144,6 +170,11 @@ Passing a command executed right after login
|
|||||||
http://localhost:8888/?command=pwd
|
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
|
Passing a terminal type
|
||||||
```bash
|
```bash
|
||||||
http://localhost:8888/?term=xterm-256color
|
http://localhost:8888/?term=xterm-256color
|
||||||
@@ -153,14 +184,43 @@ http://localhost:8888/?term=xterm-256color
|
|||||||
|
|
||||||
Start up the app
|
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
|
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
|
### Tests
|
||||||
|
|
||||||
Requirements
|
Requirements
|
||||||
|
|||||||
+33
-4
@@ -41,7 +41,7 @@ How it works
|
|||||||
Requirements
|
Requirements
|
||||||
~~~~~~~~~~~~
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
- Python 3.8+
|
- Python 3.10+
|
||||||
|
|
||||||
Quickstart
|
Quickstart
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
@@ -49,7 +49,30 @@ Quickstart
|
|||||||
1. Install this app, run command ``pip install webssh``
|
1. Install this app, run command ``pip install webssh``
|
||||||
2. Start a webserver, run command ``wssh``
|
2. Start a webserver, run command ``wssh``
|
||||||
3. Open your browser, navigate to ``127.0.0.1:8888``
|
3. Open your browser, navigate to ``127.0.0.1:8888``
|
||||||
4. Input your data, submit the form.
|
4. Create the WebSSH administrator account on first visit
|
||||||
|
5. Input your ssh data, submit the form.
|
||||||
|
|
||||||
|
WebSSH login and saved connections
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
WebSSH requires a login before opening ssh sessions. On first startup, if
|
||||||
|
no administrator password has been configured, the login page will ask you
|
||||||
|
to create one. The password is stored as a PBKDF2 hash under the data
|
||||||
|
directory, not as plain text.
|
||||||
|
|
||||||
|
You can also provide credentials with environment variables:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
WEBSSH_AUTH_USERNAME=admin WEBSSH_AUTH_PASSWORD='strong-password' wssh
|
||||||
|
|
||||||
|
Saved connections are stored under the data directory too. WebSSH saves
|
||||||
|
hostname, port, username, 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
|
Server options
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
@@ -71,6 +94,9 @@ Server options
|
|||||||
# log to file
|
# log to file
|
||||||
wssh --log-file-prefix=main.log
|
wssh --log-file-prefix=main.log
|
||||||
|
|
||||||
|
# data directory for login and saved connection data
|
||||||
|
wssh --data-dir='/path/to/webssh-data'
|
||||||
|
|
||||||
# more options
|
# more options
|
||||||
wssh --help
|
wssh --help
|
||||||
|
|
||||||
@@ -162,13 +188,16 @@ Start up the app
|
|||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
docker-compose up
|
docker compose up -d
|
||||||
|
|
||||||
Tear down the app
|
Tear down the app
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
docker-compose down
|
docker compose down
|
||||||
|
|
||||||
|
The bundled compose file persists login and saved connection data in the
|
||||||
|
``webssh-data`` volume mounted at ``/data`` inside the container.
|
||||||
|
|
||||||
Tests
|
Tests
|
||||||
~~~~~
|
~~~~~
|
||||||
|
|||||||
+18
-3
@@ -1,6 +1,21 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
web:
|
webssh:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
PYTHON_IMAGE: ${PYTHON_IMAGE:-docker.io/python:3.12-slim}
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8888:8888"
|
- "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
@@ -1,2 +1,2 @@
|
|||||||
paramiko==3.0.0
|
paramiko==3.5.1
|
||||||
tornado==6.2.0
|
tornado==6.5.1
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ setup(
|
|||||||
classifiers=[
|
classifiers=[
|
||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 3',
|
'Programming Language :: Python :: 3',
|
||||||
'Programming Language :: Python :: 3.8',
|
|
||||||
'Programming Language :: Python :: 3.9',
|
|
||||||
'Programming Language :: Python :: 3.10',
|
'Programming Language :: Python :: 3.10',
|
||||||
'Programming Language :: Python :: 3.11',
|
'Programming Language :: Python :: 3.11',
|
||||||
|
'Programming Language :: Python :: 3.12',
|
||||||
],
|
],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'tornado>=4.5.0',
|
'tornado>=4.5.0',
|
||||||
|
|||||||
+7
-2
@@ -20,6 +20,9 @@ print('Read key: ' + hexlify(host_key.get_fingerprint()).decode('utf-8'))
|
|||||||
|
|
||||||
banner = u'\r\n\u6b22\u8fce\r\n'
|
banner = u'\r\n\u6b22\u8fce\r\n'
|
||||||
event_timeout = 5
|
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):
|
class Server(paramiko.ServerInterface):
|
||||||
@@ -41,6 +44,7 @@ class Server(paramiko.ServerInterface):
|
|||||||
self.shell_event = threading.Event()
|
self.shell_event = threading.Event()
|
||||||
self.exec_event = threading.Event()
|
self.exec_event = threading.Event()
|
||||||
self.cmd_to_enc = self.get_cmd2enc(encodings)
|
self.cmd_to_enc = self.get_cmd2enc(encodings)
|
||||||
|
self.encoding = 'UTF-8'
|
||||||
self.password_verified = False
|
self.password_verified = False
|
||||||
self.key_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.')
|
print('*** Client never asked for a shell.')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
server.exec_event.wait(timeout=event_timeout)
|
server.exec_event.wait(timeout=exec_timeout)
|
||||||
if not server.exec_event.is_set():
|
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.')
|
print('*** Client never asked for a command.')
|
||||||
continue
|
|
||||||
|
|
||||||
# chan.send('\r\n\r\nWelcome!\r\n\r\n')
|
# chan.send('\r\n\r\nWelcome!\r\n\r\n')
|
||||||
print(server.encoding)
|
print(server.encoding)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import tornado.websocket
|
import tornado.websocket
|
||||||
import tornado.gen
|
import tornado.gen
|
||||||
@@ -29,6 +31,18 @@ server_encodings = {e.strip() for e in Server.encodings}
|
|||||||
|
|
||||||
class TestAppBase(AsyncHTTPTestCase):
|
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):
|
def get_httpserver_options(self):
|
||||||
return get_server_settings(options)
|
return get_server_settings(options)
|
||||||
|
|
||||||
@@ -99,6 +113,7 @@ class TestAppBasic(TestAppBase):
|
|||||||
options.syshostfile = ''
|
options.syshostfile = ''
|
||||||
options.tdstream = ''
|
options.tdstream = ''
|
||||||
options.delay = 0.1
|
options.delay = 0.1
|
||||||
|
options.auth = False
|
||||||
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -326,6 +341,48 @@ class TestAppBasic(TestAppBase):
|
|||||||
self.assertEqual(b'bye', msg)
|
self.assertEqual(b'bye', msg)
|
||||||
ws.close()
|
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
|
@tornado.testing.gen_test
|
||||||
def test_app_auth_with_valid_pubkey_by_urlencoded_form(self):
|
def test_app_auth_with_valid_pubkey_by_urlencoded_form(self):
|
||||||
url = self.get_url('/')
|
url = self.get_url('/')
|
||||||
@@ -536,6 +593,7 @@ class OtherTestBase(TestAppBase):
|
|||||||
options.tdstream = self.tdstream
|
options.tdstream = self.tdstream
|
||||||
options.maxconn = self.maxconn
|
options.maxconn = self.maxconn
|
||||||
options.origin = self.origin
|
options.origin = self.origin
|
||||||
|
options.auth = False
|
||||||
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -790,3 +848,57 @@ class TestAppWithUnknownEncoding(OtherTestBase):
|
|||||||
dic = json.loads(to_str(response.body))
|
dic = json.loads(to_str(response.body))
|
||||||
self.assert_status_none(dic)
|
self.assert_status_none(dic)
|
||||||
self.assertEqual(dic['encoding'], 'utf-8')
|
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))
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from webssh.auth import AuthManager, make_password_hash, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmpdir)
|
||||||
|
|
||||||
|
def test_password_hash(self):
|
||||||
|
encoded = make_password_hash('secret')
|
||||||
|
self.assertTrue(verify_password('secret', encoded))
|
||||||
|
self.assertFalse(verify_password('wrong', encoded))
|
||||||
|
self.assertFalse(verify_password('', encoded))
|
||||||
|
|
||||||
|
def test_auth_manager_setup_and_verify(self):
|
||||||
|
auth_file = os.path.join(self.tmpdir, 'auth.json')
|
||||||
|
manager = AuthManager(auth_file=auth_file)
|
||||||
|
self.assertFalse(manager.is_configured())
|
||||||
|
|
||||||
|
manager.setup('admin', 'secret')
|
||||||
|
self.assertTrue(manager.is_configured())
|
||||||
|
self.assertTrue(manager.verify('admin', 'secret'))
|
||||||
|
self.assertFalse(manager.verify('admin', 'wrong'))
|
||||||
|
self.assertFalse(manager.verify('other', 'secret'))
|
||||||
|
|
||||||
|
loaded = AuthManager(auth_file=auth_file)
|
||||||
|
self.assertTrue(loaded.verify('admin', 'secret'))
|
||||||
|
|
||||||
@@ -0,0 +1,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
@@ -8,7 +8,7 @@ from tests.utils import read_file, make_tests_data_path
|
|||||||
from webssh import handler
|
from webssh import handler
|
||||||
from webssh import worker
|
from webssh import worker
|
||||||
from webssh.handler import (
|
from webssh.handler import (
|
||||||
IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient
|
IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient, LoginRateLimiter, EncodingCache
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -121,7 +121,7 @@ class TestMixinHandler(unittest.TestCase):
|
|||||||
self.assertEqual(mhandler.get_real_client_addr(),
|
self.assertEqual(mhandler.get_real_client_addr(),
|
||||||
(x_forwarded_for, fake_port))
|
(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(),
|
self.assertEqual(mhandler.get_real_client_addr(),
|
||||||
(x_forwarded_for, fake_port))
|
(x_forwarded_for, fake_port))
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class TestMixinHandler(unittest.TestCase):
|
|||||||
self.assertEqual(mhandler.get_real_client_addr(),
|
self.assertEqual(mhandler.get_real_client_addr(),
|
||||||
(x_real_ip, fake_port))
|
(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(),
|
self.assertEqual(mhandler.get_real_client_addr(),
|
||||||
(x_real_ip, fake_port))
|
(x_real_ip, fake_port))
|
||||||
|
|
||||||
@@ -318,16 +318,103 @@ class TestWsockHandler(unittest.TestCase):
|
|||||||
obj.close.assert_called_with(reason='Worker closed')
|
obj.close.assert_called_with(reason='Worker closed')
|
||||||
|
|
||||||
class TestIndexHandler(unittest.TestCase):
|
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):
|
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
|
# This is a little nasty, but the index handler has a lot of
|
||||||
# dependencies to mock. Mocking out everything but the bits
|
# dependencies to mock. Mocking out everything but the bits
|
||||||
# we want to test lets us test this case without needing to
|
# we want to test lets us test this case without needing to
|
||||||
# refactor the relevant code out of IndexHandler
|
# refactor the relevant code out of IndexHandler
|
||||||
def parse_encoding(data):
|
def parse_encoding(data):
|
||||||
return IndexHandler.parse_encoding(handler, data)
|
return IndexHandler.parse_encoding(mock_handler, data)
|
||||||
handler.parse_encoding = parse_encoding
|
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)
|
ssh = Mock(spec=SSHClient)
|
||||||
stdin = io.BytesIO()
|
stdin = io.BytesIO()
|
||||||
@@ -335,6 +422,184 @@ class TestIndexHandler(unittest.TestCase):
|
|||||||
stderr = io.BytesIO()
|
stderr = io.BytesIO()
|
||||||
ssh.exec_command.return_value = (stdin, stdout, stderr)
|
ssh.exec_command.return_value = (stdin, stdout, stderr)
|
||||||
|
|
||||||
encoding = IndexHandler.get_default_encoding(handler, ssh)
|
self.assertIsNone(IndexHandler.detect_encoding(mock_handler, ssh))
|
||||||
self.assertEquals("utf-8", encoding)
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,8 @@ import unittest
|
|||||||
|
|
||||||
from webssh.utils import (
|
from webssh.utils import (
|
||||||
is_valid_ip_address, is_valid_port, is_valid_hostname, to_str, to_bytes,
|
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('127.0.0.1'))
|
||||||
self.assertFalse(is_valid_hostname('::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):
|
def test_is_ip_hostname(self):
|
||||||
self.assertTrue(is_ip_hostname('[::1]'))
|
self.assertTrue(is_ip_hostname('[::1]'))
|
||||||
self.assertTrue(is_ip_hostname('127.0.0.1'))
|
self.assertTrue(is_ip_hostname('127.0.0.1'))
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
__version_info__ = (1, 6, 1)
|
__version_info__ = (1, 6, 3)
|
||||||
__version__ = '.'.join(map(str, __version_info__))
|
__version__ = '.'.join(map(str, __version_info__))
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
try:
|
||||||
|
import secrets
|
||||||
|
except ImportError:
|
||||||
|
secrets = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from uuid import uuid4
|
||||||
|
except ImportError:
|
||||||
|
uuid4 = None
|
||||||
|
|
||||||
|
|
||||||
|
HASH_NAME = 'pbkdf2_sha256'
|
||||||
|
HASH_ITERATIONS = 260000
|
||||||
|
|
||||||
|
|
||||||
|
def _token_urlsafe(nbytes=32):
|
||||||
|
if secrets:
|
||||||
|
return secrets.token_urlsafe(nbytes)
|
||||||
|
return uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def _b64encode(data):
|
||||||
|
return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
|
||||||
|
|
||||||
|
|
||||||
|
def make_password_hash(password, salt=None, iterations=HASH_ITERATIONS):
|
||||||
|
if not password:
|
||||||
|
raise ValueError('Password must not be empty.')
|
||||||
|
|
||||||
|
if salt is None:
|
||||||
|
salt = _token_urlsafe(18)
|
||||||
|
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
'sha256',
|
||||||
|
password.encode('utf-8'),
|
||||||
|
salt.encode('utf-8'),
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
return '{}${}${}${}'.format(
|
||||||
|
HASH_NAME, iterations, salt, _b64encode(digest)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password, encoded):
|
||||||
|
try:
|
||||||
|
name, iterations, salt, _ = encoded.split('$', 3)
|
||||||
|
iterations = int(iterations)
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if name != HASH_NAME:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
actual = make_password_hash(password, salt, iterations)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return hmac.compare_digest(actual, encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_username(username):
|
||||||
|
return bool(username and username.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class AuthManager(object):
|
||||||
|
|
||||||
|
def __init__(self, username='admin', password='', password_hash='',
|
||||||
|
auth_file=''):
|
||||||
|
self.username = username or 'admin'
|
||||||
|
self.password_hash = password_hash
|
||||||
|
if password and not self.password_hash:
|
||||||
|
self.password_hash = make_password_hash(password)
|
||||||
|
self.auth_file = auth_file
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
if not self.password_hash:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def is_configured(self):
|
||||||
|
return bool(self.password_hash)
|
||||||
|
|
||||||
|
def is_valid_username(self, username):
|
||||||
|
return hmac.compare_digest(username or '', self.username or '')
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
if not self.auth_file or not os.path.isfile(self.auth_file):
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
with open(self.auth_file, encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
username = data.get('username')
|
||||||
|
password_hash = data.get('password_hash')
|
||||||
|
if username and password_hash:
|
||||||
|
self.username = username
|
||||||
|
self.password_hash = password_hash
|
||||||
|
|
||||||
|
def setup(self, username, password):
|
||||||
|
username = username.strip()
|
||||||
|
if not is_safe_username(username):
|
||||||
|
raise ValueError('用户名不能为空。')
|
||||||
|
|
||||||
|
password_hash = make_password_hash(password)
|
||||||
|
data = {
|
||||||
|
'username': username,
|
||||||
|
'password_hash': password_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
if self.password_hash:
|
||||||
|
raise ValueError('认证信息已配置。')
|
||||||
|
|
||||||
|
dirname = os.path.dirname(self.auth_file)
|
||||||
|
if dirname and not os.path.isdir(dirname):
|
||||||
|
os.makedirs(dirname)
|
||||||
|
|
||||||
|
tmp = self.auth_file + '.tmp'
|
||||||
|
with open(tmp, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2, sort_keys=True)
|
||||||
|
f.write('\n')
|
||||||
|
_chmod_private(tmp)
|
||||||
|
os.replace(tmp, self.auth_file)
|
||||||
|
_chmod_private(self.auth_file)
|
||||||
|
|
||||||
|
self.username = username
|
||||||
|
self.password_hash = password_hash
|
||||||
|
|
||||||
|
def verify(self, username, password):
|
||||||
|
if not self.password_hash:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
if not self.password_hash:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.is_valid_username(username) and
|
||||||
|
verify_password(password, self.password_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chmod_private(path):
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -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
@@ -3,6 +3,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import weakref
|
import weakref
|
||||||
import paramiko
|
import paramiko
|
||||||
@@ -15,7 +17,7 @@ from tornado.process import cpu_count
|
|||||||
from webssh.utils import (
|
from webssh.utils import (
|
||||||
is_valid_ip_address, is_valid_port, is_valid_hostname, to_bytes, to_str,
|
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,
|
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
|
from webssh.worker import Worker, recycle_worker, clients
|
||||||
|
|
||||||
@@ -36,6 +38,81 @@ swallow_http_errors = True
|
|||||||
redirecting = None
|
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):
|
class InvalidValueError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -265,6 +342,34 @@ class MixinHandler(object):
|
|||||||
port = '' if port == 443 else ':%s' % port
|
port = '' if port == 443 else ':%s' % port
|
||||||
return 'https://{}{}{}'.format(hostname, port, uri)
|
return 'https://{}{}{}'.format(hostname, port, uri)
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
auth_manager = self.settings.get('auth_manager')
|
||||||
|
if not auth_manager:
|
||||||
|
return 'anonymous'
|
||||||
|
|
||||||
|
username = self.get_secure_cookie('webssh_user')
|
||||||
|
if not username:
|
||||||
|
return
|
||||||
|
|
||||||
|
username = to_str(username)
|
||||||
|
if auth_manager.is_valid_username(username):
|
||||||
|
return username
|
||||||
|
|
||||||
|
def get_safe_next_url(self):
|
||||||
|
next_url = self.get_argument('next', u'/')
|
||||||
|
parsed = urlparse(next_url)
|
||||||
|
if parsed.scheme or parsed.netloc or not next_url.startswith('/'):
|
||||||
|
return '/'
|
||||||
|
return next_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_manager(self):
|
||||||
|
return self.settings.get('auth_manager')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection_store(self):
|
||||||
|
return self.settings.get('connection_store')
|
||||||
|
|
||||||
def set_default_headers(self):
|
def set_default_headers(self):
|
||||||
for header in self.custom_headers.items():
|
for header in self.custom_headers.items():
|
||||||
self.set_header(*header)
|
self.set_header(*header)
|
||||||
@@ -284,6 +389,17 @@ class MixinHandler(object):
|
|||||||
else:
|
else:
|
||||||
return self.get_context_addr()
|
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):
|
def get_real_client_addr(self):
|
||||||
ip = self.request.remote_ip
|
ip = self.request.remote_ip
|
||||||
|
|
||||||
@@ -303,6 +419,135 @@ class MixinHandler(object):
|
|||||||
return (ip, port)
|
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):
|
class NotFoundHandler(MixinHandler, tornado.web.ErrorHandler):
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
@@ -387,6 +632,30 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
hostname, port)
|
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):
|
def get_args(self):
|
||||||
hostname = self.get_hostname()
|
hostname = self.get_hostname()
|
||||||
port = self.get_port()
|
port = self.get_port()
|
||||||
@@ -395,6 +664,11 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
privatekey, filename = self.get_privatekey()
|
privatekey, filename = self.get_privatekey()
|
||||||
passphrase = self.get_argument('passphrase', u'')
|
passphrase = self.get_argument('passphrase', u'')
|
||||||
totp = self.get_argument('totp', u'')
|
totp = self.get_argument('totp', u'')
|
||||||
|
term = self.get_argument('term', u'') or u'xterm'
|
||||||
|
directory = self.get_directory()
|
||||||
|
|
||||||
|
if not password and not privatekey:
|
||||||
|
password = self.get_saved_password(hostname, port, username)
|
||||||
|
|
||||||
if isinstance(self.policy, paramiko.RejectPolicy):
|
if isinstance(self.policy, paramiko.RejectPolicy):
|
||||||
self.lookup_hostname(hostname, port)
|
self.lookup_hostname(hostname, port)
|
||||||
@@ -405,8 +679,17 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
pkey = None
|
pkey = None
|
||||||
|
|
||||||
self.ssh_client.totp = totp
|
self.ssh_client.totp = totp
|
||||||
args = (hostname, port, username, password, pkey)
|
args = (hostname, port, username, password, pkey, term, directory)
|
||||||
logging.debug(args)
|
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
|
return args
|
||||||
|
|
||||||
@@ -420,6 +703,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
return encoding
|
return encoding
|
||||||
|
|
||||||
def get_default_encoding(self, ssh):
|
def get_default_encoding(self, ssh):
|
||||||
|
return self.detect_encoding(ssh) or 'utf-8'
|
||||||
|
|
||||||
|
def detect_encoding(self, ssh):
|
||||||
commands = [
|
commands = [
|
||||||
'$SHELL -ilc "locale charmap"',
|
'$SHELL -ilc "locale charmap"',
|
||||||
'$SHELL -ic "locale charmap"'
|
'$SHELL -ic "locale charmap"'
|
||||||
@@ -444,15 +730,17 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
logging.warning('Could not detect the default encoding.')
|
logging.warning('Could not detect the default encoding.')
|
||||||
return 'utf-8'
|
|
||||||
|
|
||||||
def ssh_connect(self, args):
|
def ssh_connect(self, args):
|
||||||
ssh = self.ssh_client
|
ssh = self.ssh_client
|
||||||
dst_addr = args[:2]
|
dst_addr = args[:2]
|
||||||
|
connect_args = args[:5]
|
||||||
|
term = args[5]
|
||||||
|
directory = args[6]
|
||||||
logging.info('Connecting to {}:{}'.format(*dst_addr))
|
logging.info('Connecting to {}:{}'.format(*dst_addr))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ssh.connect(*args, timeout=options.timeout)
|
ssh.connect(*connect_args, timeout=options.timeout)
|
||||||
except socket.error:
|
except socket.error:
|
||||||
raise ValueError('Unable to connect to {}:{}'.format(*dst_addr))
|
raise ValueError('Unable to connect to {}:{}'.format(*dst_addr))
|
||||||
except paramiko.BadAuthenticationType:
|
except paramiko.BadAuthenticationType:
|
||||||
@@ -462,14 +750,44 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
except paramiko.BadHostKeyException:
|
except paramiko.BadHostKeyException:
|
||||||
raise ValueError('Bad host key.')
|
raise ValueError('Bad host key.')
|
||||||
|
|
||||||
term = self.get_argument('term', u'') or u'xterm'
|
|
||||||
chan = ssh.invoke_shell(term=term)
|
chan = ssh.invoke_shell(term=term)
|
||||||
|
if directory:
|
||||||
|
self.change_directory(chan, directory)
|
||||||
chan.setblocking(0)
|
chan.setblocking(0)
|
||||||
worker = Worker(self.loop, ssh, chan, dst_addr)
|
worker = Worker(self.loop, ssh, chan, dst_addr)
|
||||||
worker.encoding = options.encoding if options.encoding else \
|
worker.encoding = options.encoding if options.encoding else \
|
||||||
self.get_default_encoding(ssh)
|
self.get_encoding(ssh, args[:3])
|
||||||
return worker
|
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):
|
def check_origin(self):
|
||||||
event_origin = self.get_argument('_origin', u'')
|
event_origin = self.get_argument('_origin', u'')
|
||||||
header_origin = self.request.headers.get('Origin')
|
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':
|
if not event_origin and self.origin_policy != 'same':
|
||||||
self.set_header('Access-Control-Allow-Origin', origin)
|
self.set_header('Access-Control-Allow-Origin', origin)
|
||||||
|
|
||||||
|
@tornado.web.authenticated
|
||||||
def head(self):
|
def head(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@tornado.web.authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
self.render('index.html', debug=self.debug, font=self.font)
|
self.render(
|
||||||
|
'index.html',
|
||||||
|
debug=self.debug,
|
||||||
|
font=self.font,
|
||||||
|
user=self.current_user,
|
||||||
|
auth_enabled=bool(self.auth_manager)
|
||||||
|
)
|
||||||
|
|
||||||
@tornado.gen.coroutine
|
@tornado.gen.coroutine
|
||||||
|
@tornado.web.authenticated
|
||||||
def post(self):
|
def post(self):
|
||||||
if self.debug and self.get_argument('error', u''):
|
if self.debug and self.get_argument('error', u''):
|
||||||
# for testing purpose only
|
# for testing purpose only
|
||||||
raise ValueError('Uncaught exception')
|
raise ValueError('Uncaught exception')
|
||||||
|
|
||||||
ip, port = self.get_client_addr()
|
ip, port = self.get_client_addr()
|
||||||
workers = clients.get(ip, {})
|
client_key = self.get_client_key()
|
||||||
if workers and len(workers) >= options.maxconn:
|
workers = clients.get(client_key, {})
|
||||||
|
if len(workers) >= options.maxconn:
|
||||||
raise tornado.web.HTTPError(403, 'Too many live connections.')
|
raise tornado.web.HTTPError(403, 'Too many live connections.')
|
||||||
|
|
||||||
self.check_origin()
|
self.check_origin()
|
||||||
@@ -517,14 +845,29 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
|
|||||||
self.result.update(status=str(exc))
|
self.result.update(status=str(exc))
|
||||||
else:
|
else:
|
||||||
if not workers:
|
if not workers:
|
||||||
clients[ip] = workers
|
clients[client_key] = workers
|
||||||
worker.src_addr = (ip, port)
|
worker.src_addr = (ip, port)
|
||||||
|
worker.client_key = client_key
|
||||||
workers[worker.id] = worker
|
workers[worker.id] = worker
|
||||||
self.loop.call_later(options.delay, recycle_worker, worker)
|
self.loop.call_later(options.delay, recycle_worker, worker)
|
||||||
self.result.update(id=worker.id, encoding=worker.encoding)
|
self.result.update(id=worker.id, encoding=worker.encoding)
|
||||||
|
profile = self.save_connection()
|
||||||
|
if profile:
|
||||||
|
self.result.update(profile=profile)
|
||||||
|
|
||||||
self.write(self.result)
|
self.write(self.result)
|
||||||
|
|
||||||
|
def save_connection(self):
|
||||||
|
if not self.get_argument('save', u''):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.connection_store.upsert(
|
||||||
|
self.current_user, self.connection_info
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
|
class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
|
||||||
|
|
||||||
@@ -533,10 +876,14 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
|
|||||||
self.worker_ref = None
|
self.worker_ref = None
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
|
if not self.current_user:
|
||||||
|
self.close(reason='Login required.')
|
||||||
|
return
|
||||||
|
|
||||||
self.src_addr = self.get_client_addr()
|
self.src_addr = self.get_client_addr()
|
||||||
logging.info('Connected from {}:{}'.format(*self.src_addr))
|
logging.info('Connected from {}:{}'.format(*self.src_addr))
|
||||||
|
|
||||||
workers = clients.get(self.src_addr[0])
|
workers = clients.get(self.get_client_key())
|
||||||
if not workers:
|
if not workers:
|
||||||
self.close(reason='Websocket authentication failed.')
|
self.close(reason='Websocket authentication failed.')
|
||||||
return
|
return
|
||||||
|
|||||||
+24
-1
@@ -4,7 +4,10 @@ import tornado.ioloop
|
|||||||
|
|
||||||
from tornado.options import options
|
from tornado.options import options
|
||||||
from webssh import handler
|
from webssh import handler
|
||||||
from webssh.handler import IndexHandler, WsockHandler, NotFoundHandler
|
from webssh.handler import (
|
||||||
|
ConnectionHandler, ConnectionsHandler, IndexHandler, LoginHandler,
|
||||||
|
LogoutHandler, WsockHandler, NotFoundHandler
|
||||||
|
)
|
||||||
from webssh.settings import (
|
from webssh.settings import (
|
||||||
get_app_settings, get_host_keys_settings, get_policy_setting,
|
get_app_settings, get_host_keys_settings, get_policy_setting,
|
||||||
get_ssl_context, get_server_settings, check_encoding_setting
|
get_ssl_context, get_server_settings, check_encoding_setting
|
||||||
@@ -16,6 +19,10 @@ def make_handlers(loop, options):
|
|||||||
policy = get_policy_setting(options, host_keys_settings)
|
policy = get_policy_setting(options, host_keys_settings)
|
||||||
|
|
||||||
handlers = [
|
handlers = [
|
||||||
|
(r'/login', LoginHandler),
|
||||||
|
(r'/logout', LogoutHandler),
|
||||||
|
(r'/connections', ConnectionsHandler),
|
||||||
|
(r'/connections/([a-f0-9]{24})', ConnectionHandler),
|
||||||
(r'/', IndexHandler, dict(loop=loop, policy=policy,
|
(r'/', IndexHandler, dict(loop=loop, policy=policy,
|
||||||
host_keys_settings=host_keys_settings)),
|
host_keys_settings=host_keys_settings)),
|
||||||
(r'/ws', WsockHandler, dict(loop=loop))
|
(r'/ws', WsockHandler, dict(loop=loop))
|
||||||
@@ -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():
|
def main():
|
||||||
options.parse_command_line()
|
options.parse_command_line()
|
||||||
check_encoding_setting(options.encoding)
|
check_encoding_setting(options.encoding)
|
||||||
@@ -47,6 +69,7 @@ def main():
|
|||||||
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
app = make_app(make_handlers(loop, options), get_app_settings(options))
|
||||||
ssl_ctx = get_ssl_context(options)
|
ssl_ctx = get_ssl_context(options)
|
||||||
server_settings = get_server_settings(options)
|
server_settings = get_server_settings(options)
|
||||||
|
check_trusted_downstream(options, server_settings)
|
||||||
app_listen(app, options.port, options.address, server_settings)
|
app_listen(app, options.port, options.address, server_settings)
|
||||||
if ssl_ctx:
|
if ssl_ctx:
|
||||||
server_settings.update(ssl_options=ssl_ctx)
|
server_settings.update(ssl_options=ssl_ctx)
|
||||||
|
|||||||
+90
-1
@@ -1,12 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
import os.path
|
import os.path
|
||||||
|
import secrets
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from tornado.options import define
|
from tornado.options import define
|
||||||
|
from webssh.auth import AuthManager
|
||||||
from webssh.policy import (
|
from webssh.policy import (
|
||||||
load_host_keys, get_policy_class, check_policy_setting
|
load_host_keys, get_policy_class, check_policy_setting
|
||||||
)
|
)
|
||||||
|
from webssh.storage import ConnectionStore
|
||||||
from webssh.utils import (
|
from webssh.utils import (
|
||||||
to_ip_address, parse_origin_from_url, is_valid_encoding
|
to_ip_address, parse_origin_from_url, is_valid_encoding
|
||||||
)
|
)
|
||||||
@@ -42,7 +45,7 @@ define('origin', default='same', help='''Origin policy,
|
|||||||
'<domains>': custom domains policy, matches any domain in the <domains> list
|
'<domains>': custom domains policy, matches any domain in the <domains> list
|
||||||
separated by comma;
|
separated by comma;
|
||||||
'*': wildcard policy, matches any domain, allowed in debug mode only.''')
|
'*': 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('timeout', type=float, default=3, help='SSH connection timeout')
|
||||||
define('delay', type=float, default=3, help='The delay to call recycle_worker')
|
define('delay', type=float, default=3, help='The delay to call recycle_worker')
|
||||||
define('maxconn', type=int, default=20,
|
define('maxconn', type=int, default=20,
|
||||||
@@ -51,6 +54,24 @@ define('font', default='', help='custom font filename')
|
|||||||
define('encoding', default='',
|
define('encoding', default='',
|
||||||
help='''The default character encoding of ssh servers.
|
help='''The default character encoding of ssh servers.
|
||||||
Example: --encoding='utf-8' to solve the problem with some switches&routers''')
|
Example: --encoding='utf-8' to solve the problem with some switches&routers''')
|
||||||
|
define('auth', type=bool, default=True,
|
||||||
|
help='Require a WebSSH login before connecting to ssh servers')
|
||||||
|
define('auth_username', default=os.environ.get('WEBSSH_AUTH_USERNAME',
|
||||||
|
'admin'),
|
||||||
|
help='WebSSH login username used before first-time setup')
|
||||||
|
define('auth_password', default=os.environ.get('WEBSSH_AUTH_PASSWORD', ''),
|
||||||
|
help='WebSSH login password, preferably supplied by environment')
|
||||||
|
define('auth_password_hash',
|
||||||
|
default=os.environ.get('WEBSSH_AUTH_PASSWORD_HASH', ''),
|
||||||
|
help='PBKDF2 password hash for WebSSH login')
|
||||||
|
define('auth_session_days', type=int, default=7,
|
||||||
|
help='Days before the WebSSH login session expires')
|
||||||
|
define('data_dir',
|
||||||
|
default=os.environ.get(
|
||||||
|
'WEBSSH_DATA_DIR',
|
||||||
|
os.path.join(os.path.expanduser('~'), '.webssh')
|
||||||
|
),
|
||||||
|
help='Directory used for WebSSH auth and saved connection data')
|
||||||
define('version', type=bool, help='Show version information',
|
define('version', type=bool, help='Show version information',
|
||||||
callback=print_version)
|
callback=print_version)
|
||||||
|
|
||||||
@@ -65,6 +86,8 @@ class Font(object):
|
|||||||
def __init__(self, filename, dirs):
|
def __init__(self, filename, dirs):
|
||||||
self.family = self.get_family(filename)
|
self.family = self.get_family(filename)
|
||||||
self.url = self.get_url(filename, dirs)
|
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):
|
def get_family(self, filename):
|
||||||
return filename.split('.')[0]
|
return filename.split('.')[0]
|
||||||
@@ -74,12 +97,18 @@ class Font(object):
|
|||||||
|
|
||||||
|
|
||||||
def get_app_settings(options):
|
def get_app_settings(options):
|
||||||
|
auth_manager = get_auth_manager(options)
|
||||||
|
secret = get_cookie_secret(options)
|
||||||
settings = dict(
|
settings = dict(
|
||||||
template_path=os.path.join(base_dir, 'webssh', 'templates'),
|
template_path=os.path.join(base_dir, 'webssh', 'templates'),
|
||||||
static_path=os.path.join(base_dir, 'webssh', 'static'),
|
static_path=os.path.join(base_dir, 'webssh', 'static'),
|
||||||
websocket_ping_interval=options.wpintvl,
|
websocket_ping_interval=options.wpintvl,
|
||||||
debug=options.debug,
|
debug=options.debug,
|
||||||
xsrf_cookies=options.xsrf,
|
xsrf_cookies=options.xsrf,
|
||||||
|
login_url='/login',
|
||||||
|
auth_manager=auth_manager,
|
||||||
|
auth_session_days=options.auth_session_days,
|
||||||
|
connection_store=get_connection_store(options, secret),
|
||||||
font=Font(
|
font=Font(
|
||||||
get_font_filename(options.font,
|
get_font_filename(options.font,
|
||||||
os.path.join(base_dir, *font_dirs)),
|
os.path.join(base_dir, *font_dirs)),
|
||||||
@@ -87,6 +116,8 @@ def get_app_settings(options):
|
|||||||
),
|
),
|
||||||
origin_policy=get_origin_setting(options)
|
origin_policy=get_origin_setting(options)
|
||||||
)
|
)
|
||||||
|
if auth_manager:
|
||||||
|
settings['cookie_secret'] = secret
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@@ -196,3 +227,61 @@ def get_font_filename(font, font_dir):
|
|||||||
def check_encoding_setting(encoding):
|
def check_encoding_setting(encoding):
|
||||||
if encoding and not is_valid_encoding(encoding):
|
if encoding and not is_valid_encoding(encoding):
|
||||||
raise ValueError('Unknown character encoding {!r}.'.format(encoding))
|
raise ValueError('Unknown character encoding {!r}.'.format(encoding))
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_manager(options):
|
||||||
|
if not options.auth:
|
||||||
|
return
|
||||||
|
|
||||||
|
auth_file = os.path.join(
|
||||||
|
os.path.expanduser(options.data_dir), 'auth.json'
|
||||||
|
)
|
||||||
|
return AuthManager(
|
||||||
|
username=options.auth_username,
|
||||||
|
password=options.auth_password,
|
||||||
|
password_hash=options.auth_password_hash,
|
||||||
|
auth_file=auth_file
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection_store(options, 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
|
||||||
|
|||||||
@@ -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
@@ -37,10 +37,17 @@ var wssh = {};
|
|||||||
|
|
||||||
jQuery(function($){
|
jQuery(function($){
|
||||||
var status = $('#status'),
|
var status = $('#status'),
|
||||||
button = $('.btn-primary'),
|
button = $('#connect .btn-primary'),
|
||||||
form_container = $('.form-container'),
|
form_container = $('.form-container'),
|
||||||
waiter = $('#waiter'),
|
waiter = $('#waiter'),
|
||||||
|
saved_connections_select = $('#saved-connections'),
|
||||||
|
load_connection_button = $('#load-connection'),
|
||||||
|
delete_connection_button = $('#delete-connection'),
|
||||||
|
show_password_checkbox = $('#show-password'),
|
||||||
term_type = $('#term'),
|
term_type = $('#term'),
|
||||||
|
quickbar = $('#quickbar'),
|
||||||
|
terminal_toast = $('#terminal-toast'),
|
||||||
|
toast_timer,
|
||||||
style = {},
|
style = {},
|
||||||
default_title = 'WebSSH',
|
default_title = 'WebSSH',
|
||||||
title_element = document.querySelector('title'),
|
title_element = document.querySelector('title'),
|
||||||
@@ -48,17 +55,21 @@ jQuery(function($){
|
|||||||
debug = document.querySelector(form_id).noValidate,
|
debug = document.querySelector(form_id).noValidate,
|
||||||
custom_font = document.fonts ? document.fonts.values().next().value : undefined,
|
custom_font = document.fonts ? document.fonts.values().next().value : undefined,
|
||||||
default_fonts,
|
default_fonts,
|
||||||
|
current_terminal,
|
||||||
DISCONNECTED = 0,
|
DISCONNECTED = 0,
|
||||||
CONNECTING = 1,
|
CONNECTING = 1,
|
||||||
CONNECTED = 2,
|
CONNECTED = 2,
|
||||||
state = DISCONNECTED,
|
state = DISCONNECTED,
|
||||||
messages = {1: 'This client is connecting ...', 2: 'This client is already connnected.'},
|
messages = {1: '正在连接,请稍候 ...', 2: '当前客户端已经连接。'},
|
||||||
key_max_size = 16384,
|
key_max_size = 16384,
|
||||||
|
directory_max_size = 1024,
|
||||||
fields = ['hostname', 'port', 'username'],
|
fields = ['hostname', 'port', 'username'],
|
||||||
form_keys = fields.concat(['password', 'totp']),
|
form_keys = fields.concat(['password', 'totp', 'directory']),
|
||||||
opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor'],
|
opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'],
|
||||||
url_form_data = {},
|
url_form_data = {},
|
||||||
url_opts_data = {},
|
url_opts_data = {},
|
||||||
|
saved_connections = [],
|
||||||
|
saved_connection_map = {},
|
||||||
validated_form_data,
|
validated_form_data,
|
||||||
event_origin,
|
event_origin,
|
||||||
hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/;
|
hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/;
|
||||||
@@ -90,6 +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) {
|
function populate_form(data) {
|
||||||
var names = form_keys.concat(['passphrase']),
|
var names = form_keys.concat(['passphrase']),
|
||||||
i, name;
|
i, name;
|
||||||
@@ -164,8 +325,18 @@ jQuery(function($){
|
|||||||
|
|
||||||
|
|
||||||
function toggle_fullscreen(term) {
|
function toggle_fullscreen(term) {
|
||||||
$('#terminal .terminal').toggleClass('fullscreen');
|
$('#terminal').toggleClass('terminal-fullscreen');
|
||||||
term.fitAddon.fit();
|
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 size = terminal_size(),
|
||||||
var rows = parseInt(window.innerHeight / style.height, 10);
|
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};
|
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) {
|
function read_as_text_with_decoder(file, callback, decoder) {
|
||||||
var reader = new window.FileReader();
|
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) {
|
function log_status(text, to_populate) {
|
||||||
|
text = translate_status(text);
|
||||||
|
if (text) {
|
||||||
console.log(text);
|
console.log(text);
|
||||||
status.html(text.split('\n').join('<br/>'));
|
}
|
||||||
|
status.text(text);
|
||||||
|
|
||||||
if (to_populate && validated_form_data) {
|
if (to_populate && validated_form_data) {
|
||||||
populate_form(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) {
|
function ajax_complete_callback(resp) {
|
||||||
button.prop('disabled', false);
|
button.prop('disabled', false);
|
||||||
|
|
||||||
@@ -358,6 +671,10 @@ jQuery(function($){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.profile) {
|
||||||
|
upsert_saved_connection(msg.profile);
|
||||||
|
}
|
||||||
|
|
||||||
var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'),
|
var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'),
|
||||||
join = (ws_url[ws_url.length-1] === '/' ? '' : '/'),
|
join = (ws_url[ws_url.length-1] === '/' ? '' : '/'),
|
||||||
url = ws_url + join + 'ws?id=' + msg.id,
|
url = ws_url + join + 'ws?id=' + msg.id,
|
||||||
@@ -368,8 +685,9 @@ jQuery(function($){
|
|||||||
termOptions = {
|
termOptions = {
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
theme: {
|
theme: {
|
||||||
background: url_opts_data.bgcolor || 'black',
|
background: url_opts_data.bgcolor || '#0f1720',
|
||||||
foreground: url_opts_data.fontcolor || 'white'
|
foreground: url_opts_data.fontcolor || '#d9f1ee',
|
||||||
|
cursor: url_opts_data.cursor || url_opts_data.fontcolor || '#55c7ba'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -380,17 +698,19 @@ jQuery(function($){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear_terminal();
|
||||||
var term = new window.Terminal(termOptions);
|
var term = new window.Terminal(termOptions);
|
||||||
|
current_terminal = term;
|
||||||
|
|
||||||
term.fitAddon = new window.FitAddon.FitAddon();
|
term.fitAddon = new window.FitAddon.FitAddon();
|
||||||
term.loadAddon(term.fitAddon);
|
term.loadAddon(term.fitAddon);
|
||||||
|
|
||||||
console.log(url);
|
console.log(url);
|
||||||
if (!msg.encoding) {
|
if (!msg.encoding) {
|
||||||
console.log('Unable to detect the default encoding of your server');
|
console.log('无法检测服务器默认编码');
|
||||||
msg.encoding = encoding;
|
msg.encoding = encoding;
|
||||||
} else {
|
} else {
|
||||||
console.log('The deault encoding of your server is ' + msg.encoding);
|
console.log('服务器默认编码是 ' + msg.encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
function term_write(text) {
|
function term_write(text) {
|
||||||
@@ -406,21 +726,21 @@ jQuery(function($){
|
|||||||
function set_encoding(new_encoding) {
|
function set_encoding(new_encoding) {
|
||||||
// for console use
|
// for console use
|
||||||
if (!new_encoding) {
|
if (!new_encoding) {
|
||||||
console.log('An encoding is required');
|
console.log('请提供编码名称');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.TextDecoder) {
|
if (!window.TextDecoder) {
|
||||||
decoder = new_encoding;
|
decoder = new_encoding;
|
||||||
encoding = decoder;
|
encoding = decoder;
|
||||||
console.log('Set encoding to ' + encoding);
|
console.log('已将编码设置为 ' + encoding);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
decoder = new window.TextDecoder(new_encoding);
|
decoder = new window.TextDecoder(new_encoding);
|
||||||
encoding = decoder.encoding;
|
encoding = decoder.encoding;
|
||||||
console.log('Set encoding to ' + encoding);
|
console.log('已将编码设置为 ' + encoding);
|
||||||
} catch (RangeError) {
|
} catch (RangeError) {
|
||||||
console.log('Unknown encoding ' + new_encoding);
|
console.log('未知编码 ' + new_encoding);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,18 +760,18 @@ jQuery(function($){
|
|||||||
wssh.geometry = function() {
|
wssh.geometry = function() {
|
||||||
// for console use
|
// for console use
|
||||||
var geometry = current_geometry(term);
|
var geometry = current_geometry(term);
|
||||||
console.log('Current window geometry: ' + JSON.stringify(geometry));
|
console.log('当前终端尺寸:' + JSON.stringify(geometry));
|
||||||
};
|
};
|
||||||
|
|
||||||
wssh.send = function(data) {
|
wssh.send = function(data) {
|
||||||
// for console use
|
// for console use
|
||||||
if (!sock) {
|
if (!sock) {
|
||||||
console.log('Websocket was already closed');
|
console.log('WebSocket 已关闭');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data !== 'string') {
|
if (typeof data !== 'string') {
|
||||||
console.log('Only string is allowed');
|
console.log('只允许发送字符串');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +787,7 @@ jQuery(function($){
|
|||||||
wssh.reset_encoding = function() {
|
wssh.reset_encoding = function() {
|
||||||
// for console use
|
// for console use
|
||||||
if (encoding === msg.encoding) {
|
if (encoding === msg.encoding) {
|
||||||
console.log('Already reset to ' + msg.encoding);
|
console.log('已经恢复为 ' + msg.encoding);
|
||||||
} else {
|
} else {
|
||||||
set_encoding(msg.encoding);
|
set_encoding(msg.encoding);
|
||||||
}
|
}
|
||||||
@@ -476,7 +796,7 @@ jQuery(function($){
|
|||||||
wssh.resize = function(cols, rows) {
|
wssh.resize = function(cols, rows) {
|
||||||
// for console use
|
// for console use
|
||||||
if (term === undefined) {
|
if (term === undefined) {
|
||||||
console.log('Terminal was already destroryed');
|
console.log('终端已经关闭');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +810,7 @@ jQuery(function($){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!valid_args) {
|
if (!valid_args) {
|
||||||
console.log('Unable to resize terminal to geometry: ' + format_geometry(cols, rows));
|
console.log('无法调整终端尺寸为:' + format_geometry(cols, rows));
|
||||||
} else {
|
} else {
|
||||||
term.on_resize(cols, rows);
|
term.on_resize(cols, rows);
|
||||||
}
|
}
|
||||||
@@ -514,7 +834,7 @@ jQuery(function($){
|
|||||||
|
|
||||||
term.on_resize = function(cols, rows) {
|
term.on_resize = function(cols, rows) {
|
||||||
if (cols !== this.cols || rows !== this.rows) {
|
if (cols !== this.cols || rows !== this.rows) {
|
||||||
console.log('Resizing terminal to geometry: ' + format_geometry(cols, rows));
|
console.log('正在调整终端尺寸为:' + format_geometry(cols, rows));
|
||||||
this.resize(cols, rows);
|
this.resize(cols, rows);
|
||||||
sock.send(JSON.stringify({'resize': [cols, rows]}));
|
sock.send(JSON.stringify({'resize': [cols, rows]}));
|
||||||
}
|
}
|
||||||
@@ -525,8 +845,132 @@ jQuery(function($){
|
|||||||
sock.send(JSON.stringify({'data': data}));
|
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() {
|
sock.onopen = function() {
|
||||||
term.open(terminal);
|
term.open(terminal);
|
||||||
|
quickbar.show();
|
||||||
|
$('body').addClass('quickbar-open');
|
||||||
toggle_fullscreen(term);
|
toggle_fullscreen(term);
|
||||||
update_font_family(term);
|
update_font_family(term);
|
||||||
term.focus();
|
term.focus();
|
||||||
@@ -548,17 +992,23 @@ jQuery(function($){
|
|||||||
};
|
};
|
||||||
|
|
||||||
sock.onclose = function(e) {
|
sock.onclose = function(e) {
|
||||||
term.dispose();
|
var reason = write_disconnect_message(term, e.reason);
|
||||||
term = undefined;
|
|
||||||
|
$('#terminal').removeClass('terminal-fullscreen');
|
||||||
|
quickbar.hide();
|
||||||
|
$('body').removeClass('quickbar-open');
|
||||||
sock = undefined;
|
sock = undefined;
|
||||||
reset_wssh();
|
reset_wssh();
|
||||||
log_status(e.reason, true);
|
log_status(reason, true);
|
||||||
|
term = undefined;
|
||||||
state = DISCONNECTED;
|
state = DISCONNECTED;
|
||||||
default_title = 'WebSSH';
|
default_title = 'WebSSH';
|
||||||
title_element.text = default_title;
|
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) {
|
if (term) {
|
||||||
resize_terminal(term);
|
resize_terminal(term);
|
||||||
}
|
}
|
||||||
@@ -602,6 +1052,7 @@ jQuery(function($){
|
|||||||
port = data.get('port'),
|
port = data.get('port'),
|
||||||
username = data.get('username'),
|
username = data.get('username'),
|
||||||
pk = data.get('privatekey'),
|
pk = data.get('privatekey'),
|
||||||
|
directory = data.get('directory'),
|
||||||
result = {
|
result = {
|
||||||
valid: false,
|
valid: false,
|
||||||
data: data,
|
data: data,
|
||||||
@@ -610,10 +1061,10 @@ jQuery(function($){
|
|||||||
errors = [], size;
|
errors = [], size;
|
||||||
|
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
errors.push('Value of hostname is required.');
|
errors.push('请输入主机地址。');
|
||||||
} else {
|
} else {
|
||||||
if (!hostname_tester.test(hostname)) {
|
if (!hostname_tester.test(hostname)) {
|
||||||
errors.push('Invalid hostname: ' + hostname);
|
errors.push('主机地址无效:' + hostname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,18 +1072,25 @@ jQuery(function($){
|
|||||||
port = 22;
|
port = 22;
|
||||||
} else {
|
} else {
|
||||||
if (!(port > 0 && port <= 65535)) {
|
if (!(port > 0 && port <= 65535)) {
|
||||||
errors.push('Invalid port: ' + port);
|
errors.push('端口无效:' + port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
errors.push('Value of username is required.');
|
errors.push('请输入 SSH 用户名。');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pk) {
|
if (pk) {
|
||||||
size = pk.size || pk.length;
|
size = pk.size || pk.length;
|
||||||
if (size > key_max_size) {
|
if (size > key_max_size) {
|
||||||
errors.push('Invalid private key: ' + pk.name || '');
|
errors.push('私钥文件无效:' + pk.name || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (pk && pk.size && !debug) {
|
||||||
read_file_as_text(pk, function(text) {
|
read_file_as_text(pk, function(text) {
|
||||||
if (text === undefined) {
|
if (text === undefined) {
|
||||||
log_status('Invalid private key: ' + pk.name);
|
log_status('私钥文件无效:' + pk.name);
|
||||||
} else {
|
} else {
|
||||||
ajax_post();
|
ajax_post();
|
||||||
}
|
}
|
||||||
@@ -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
|
// for console use
|
||||||
var result, opts;
|
var result, opts;
|
||||||
|
|
||||||
@@ -768,7 +1226,8 @@ jQuery(function($){
|
|||||||
password: password,
|
password: password,
|
||||||
privatekey: privatekey,
|
privatekey: privatekey,
|
||||||
passphrase: passphrase,
|
passphrase: passphrase,
|
||||||
totp: totp
|
totp: totp,
|
||||||
|
directory: directory
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
opts = hostname;
|
opts = hostname;
|
||||||
@@ -794,23 +1253,82 @@ jQuery(function($){
|
|||||||
connect();
|
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)
|
function cross_origin_connect(event)
|
||||||
{
|
{
|
||||||
console.log(event.origin);
|
|
||||||
var prop = 'connect',
|
var prop = 'connect',
|
||||||
args;
|
args = parse_connect_message(event.data);
|
||||||
|
|
||||||
try {
|
if (args === undefined) {
|
||||||
args = JSON.parse(event.data);
|
return;
|
||||||
} catch (SyntaxError) {
|
|
||||||
args = event.data.split('|');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(args)) {
|
if (!Array.isArray(args)) {
|
||||||
args = [args];
|
args = [args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(event.origin);
|
||||||
try {
|
try {
|
||||||
event_origin = event.origin;
|
event_origin = event.origin;
|
||||||
wssh[prop].apply(wssh, args);
|
wssh[prop].apply(wssh, args);
|
||||||
@@ -844,12 +1362,13 @@ jQuery(function($){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url_form_data.password === null) {
|
if (url_form_data.password === null) {
|
||||||
log_status('Password via url must be encoded in base64.');
|
log_status('URL 中的密码必须使用 base64 编码。');
|
||||||
} else {
|
} else {
|
||||||
if (get_object_length(url_form_data)) {
|
if (get_object_length(url_form_data)) {
|
||||||
waiter.show();
|
waiter.show();
|
||||||
connect(url_form_data);
|
connect(url_form_data);
|
||||||
} else {
|
} else {
|
||||||
|
load_saved_connections();
|
||||||
restore_items(fields);
|
restore_items(fields);
|
||||||
form_container.show();
|
form_container.show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+137
-72
@@ -1,101 +1,166 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title> WebSSH </title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link href="static/img/favicon.png" rel="icon" type="image/png">
|
<title>WebSSH</title>
|
||||||
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
|
<link href="{{ static_url('img/favicon.png') }}" rel="icon" type="image/png">
|
||||||
<link href="static/css/xterm.min.css" rel="stylesheet" type="text/css"/>
|
<link href="{{ static_url('css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||||
<link href="static/css/fullscreen.min.css" rel="stylesheet" type="text/css"/>
|
<link href="{{ static_url('css/xterm.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||||
<style>
|
<link href="{{ static_url('css/fullscreen.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||||
.row {
|
<link href="{{ static_url('css/app.css') }}" rel="stylesheet" type="text/css"/>
|
||||||
margin-top: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
{% if font.family %}
|
{% if font.family %}
|
||||||
|
<style>
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: '{{ font.family }}';
|
font-family: '{{ font.family }}';
|
||||||
src: url('{{ font.url }}');
|
src: url('{{ static_url(font.path) }}');
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: '{{ font.family }}';
|
font-family: '{{ font.family }}';
|
||||||
}
|
}
|
||||||
{% end %}
|
|
||||||
</style>
|
</style>
|
||||||
|
{% end %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-page">
|
||||||
<div id="waiter" style="display: none"> Connecting ... </div>
|
<div id="waiter" style="display: none">
|
||||||
|
<div class="waiter-card">
|
||||||
|
<span class="waiter-spinner" aria-hidden="true"></span>
|
||||||
|
<span>正在连接 ...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container form-container" style="display: none">
|
<main class="app-shell form-container" style="display: none">
|
||||||
<form id="connect" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
<section class="app-panel">
|
||||||
<div class="row">
|
<header class="topbar">
|
||||||
<div class="col">
|
<div class="brand-block">
|
||||||
<label for="Hostname">Hostname</label>
|
<span class="brand-kicker">WEBSSH</span>
|
||||||
<input class="form-control" type="text" id="hostname" name="hostname" value="" required>
|
<h1>连接控制台</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
{% if auth_enabled %}
|
||||||
<label for="Port">Port</label>
|
<div class="session-meta">
|
||||||
<input class="form-control" type="number" id="port" name="port" placeholder="22" value="" min=1 max=65535>
|
<span class="session-user">{{ user }}</span>
|
||||||
|
<a href="/logout">退出登录</a>
|
||||||
|
</div>
|
||||||
|
{% end %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="saved-bar">
|
||||||
|
<div class="field">
|
||||||
|
<label for="saved-connections">已保存连接</label>
|
||||||
|
<select class="form-control" id="saved-connections">
|
||||||
|
<option value="">未选择保存的连接</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="saved-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="load-connection">
|
||||||
|
载入
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
id="delete-connection">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
<form id="connect" class="connection-form" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
|
||||||
<label for="Username">Username</label>
|
<div class="form-grid">
|
||||||
<input class="form-control" type="text" id="username" name="username" value="" required>
|
<div class="field">
|
||||||
|
<label for="hostname">主机地址</label>
|
||||||
|
<input class="form-control" type="text" id="hostname"
|
||||||
|
name="hostname" value="" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="field">
|
||||||
<label for="Password">Password</label>
|
<label for="port">端口</label>
|
||||||
<input class="form-control" type="password" id="password" name="password" value="">
|
<input class="form-control" type="number" id="port" name="port"
|
||||||
</div>
|
placeholder="22" value="" min=1 max=65535>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="field">
|
||||||
<div class="col">
|
<label for="username">SSH 用户名</label>
|
||||||
<label for="Username">Private Key</label>
|
<input class="form-control" type="text" id="username"
|
||||||
<input class="form-control" type="file" id="privatekey" name="privatekey" value="">
|
name="username" value="" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="field">
|
||||||
<label for="Passphrase">Passphrase</label>
|
<div class="field-heading">
|
||||||
<input class="form-control" type="password" id="passphrase" name="passphrase" value="">
|
<label for="password">SSH 密码</label>
|
||||||
</div>
|
<label class="inline-toggle" for="show-password">
|
||||||
</div>
|
<input type="checkbox" id="show-password">
|
||||||
<div class="row">
|
<span>显示</span>
|
||||||
<div class="col">
|
</label>
|
||||||
<label for="totp">Totp (time-based one-time password)</label>
|
</div>
|
||||||
<input class="form-control" type="password" id="totp" name="totp" value="">
|
<input class="form-control" type="password" id="password"
|
||||||
</div>
|
name="password" value="">
|
||||||
<div class="col">
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="term" name="term" value="xterm-256color">
|
<input type="hidden" id="term" name="term" value="xterm-256color">
|
||||||
{% module xsrf_form_html() %}
|
{% module xsrf_form_html() %}
|
||||||
<button type="submit" class="btn btn-primary">Connect</button>
|
<div class="action-row">
|
||||||
<button type="reset" class="btn btn-danger">Reset</button>
|
<button type="submit" class="btn btn-primary">连接</button>
|
||||||
|
<button type="reset" class="btn btn-outline-secondary">重置</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div class="container">
|
<section class="terminal-stage">
|
||||||
<div id="status" style="color: red;"></div>
|
<div id="status" class="status-message"></div>
|
||||||
<div id="terminal"></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>
|
</div>
|
||||||
|
|
||||||
<script src="static/js/jquery.min.js"></script>
|
<script src="{{ static_url('js/jquery.min.js') }}"></script>
|
||||||
<script src="static/js/popper.min.js"></script>
|
<script src="{{ static_url('js/popper.min.js') }}"></script>
|
||||||
<script src="static/js/bootstrap.min.js"></script>
|
<script src="{{ static_url('js/bootstrap.min.js') }}"></script>
|
||||||
<script src="static/js/xterm.min.js"></script>
|
<script src="{{ static_url('js/xterm.min.js') }}"></script>
|
||||||
<script src="static/js/xterm-addon-fit.min.js"></script>
|
<script src="{{ static_url('js/xterm-addon-fit.min.js') }}"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="{{ static_url('js/main.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -14,6 +14,9 @@ except ImportError:
|
|||||||
|
|
||||||
numeric = re.compile(r'[0-9]+$')
|
numeric = re.compile(r'[0-9]+$')
|
||||||
allowed = re.compile(r'(?!-)[a-z0-9-]{1,63}(?<!-)$', re.IGNORECASE)
|
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'):
|
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)
|
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):
|
def is_same_primary_domain(domain1, domain2):
|
||||||
i = -1
|
i = -1
|
||||||
dots = 0
|
dots = 0
|
||||||
|
|||||||
+9
-8
@@ -12,19 +12,18 @@ from tornado.util import errno_from_exception
|
|||||||
|
|
||||||
|
|
||||||
BUF_SIZE = 32 * 1024
|
BUF_SIZE = 32 * 1024
|
||||||
clients = {} # {ip: {id: worker}}
|
clients = {} # {client_key: {id: worker}}
|
||||||
|
|
||||||
|
|
||||||
def clear_worker(worker, clients):
|
def clear_worker(worker, clients):
|
||||||
ip = worker.src_addr[0]
|
key = worker.client_key
|
||||||
workers = clients.get(ip)
|
workers = clients.get(key)
|
||||||
assert worker.id in workers
|
if not workers or worker.id not in workers:
|
||||||
workers.pop(worker.id)
|
return
|
||||||
|
|
||||||
|
workers.pop(worker.id)
|
||||||
if not workers:
|
if not workers:
|
||||||
clients.pop(ip)
|
clients.pop(key, None)
|
||||||
if not clients:
|
|
||||||
clients.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def recycle_worker(worker):
|
def recycle_worker(worker):
|
||||||
@@ -46,6 +45,8 @@ class Worker(object):
|
|||||||
self.handler = None
|
self.handler = None
|
||||||
self.mode = IOLoop.READ
|
self.mode = IOLoop.READ
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
self.src_addr = None
|
||||||
|
self.client_key = None
|
||||||
|
|
||||||
def __call__(self, fd, events):
|
def __call__(self, fd, events):
|
||||||
if events & IOLoop.READ:
|
if events & IOLoop.READ:
|
||||||
|
|||||||
Reference in New Issue
Block a user