├── .coveragerc ├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── docker.yml │ └── python.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Picture1.gif ├── README.md ├── README.rst ├── docker-compose.yml ├── preview ├── login.png └── terminal.png ├── requirements.txt ├── run.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── data │ ├── cert.crt │ ├── cert.key │ ├── fonts │ │ ├── .gitignore │ │ └── fake-font │ ├── known_hosts_example │ ├── known_hosts_example2 │ ├── known_hosts_example3 │ ├── test_ed25519.key │ ├── test_ed25519_password.key │ ├── test_known_hosts │ ├── test_new_dsa.key │ ├── test_new_rsa_password.key │ ├── test_rsa.key │ ├── test_rsa_password.key │ └── user_rsa_key ├── sshserver.py ├── test_app.py ├── test_handler.py ├── test_main.py ├── test_policy.py ├── test_settings.py ├── test_utils.py └── utils.py ├── user.js └── Build-SSH-Link.user.js └── webssh ├── __init__.py ├── _version.py ├── handler.py ├── main.py ├── policy.py ├── settings.py ├── static ├── css │ ├── bootstrap.min.css │ ├── fonts │ │ ├── .gitignore │ │ └── Consolas.ttf │ ├── fullscreen.min.css │ └── xterm.min.css ├── img │ ├── favicon-16.png │ ├── favicon-32.png │ └── favicon-96.png ├── js │ ├── bootstrap.min.js │ ├── jquery.min.js │ ├── main.js │ ├── popper.min.js │ ├── service-worker.js │ ├── xterm-addon-fit.min.js │ └── xterm.min.js └── manifest.json ├── templates └── index.html ├── utils.py └── worker.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = webssh 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | tests/* 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: huashengdun 4 | ko_fi: huashengdun 5 | custom: https://bit.ly/2XmXXIP 6 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | 13 | - name: Login to Docker Hub 14 | uses: docker/login-action@v2 15 | with: 16 | username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 18 | 19 | - name: Set up QEMU # 用于多平台编译 20 | uses: docker/setup-qemu-action@v2 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | 25 | - name: Build and push multi-arch Docker image 26 | uses: docker/build-push-action@v4 27 | with: 28 | context: . 29 | push: true 30 | platforms: linux/amd64,linux/arm64 31 | tags: cmliu/webssh:latest -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # https://beta.ruff.rs 2 | name: python 3 | on: 4 | #push: 5 | # branches: [master] 6 | #pull_request: 7 | # branches: [master] 8 | workflow_dispatch: 9 | jobs: 10 | ruff: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - run: pip install --user ruff 15 | - run: ruff --format=github --ignore=F401 --target-version=py38 . 16 | pytest: 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.8", "3.9", "3.10", "3.11"] 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - run: pip install pytest pytest-cov -r requirements.txt 28 | - run: pytest --cov=webssh 29 | - run: mkdir -p coverage 30 | - uses: tj-actions/coverage-badge-py@v2 31 | with: 32 | output: coverage/coverage.svg 33 | - uses: JamesIves/github-pages-deploy-action@v4 34 | with: 35 | branch: coverage-badge 36 | folder: coverage 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .cache 39 | .pytest_cache/ 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # database file 57 | *.sqlite 58 | *.sqlite3 59 | *.db 60 | 61 | # temporary file 62 | *.swp 63 | 64 | # known_hosts file 65 | known_hosts 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | LABEL maintainer='' 4 | LABEL version='0.0.0-dev.0-build.0' 5 | 6 | ADD . /code 7 | WORKDIR /code 8 | RUN \ 9 | apk add --no-cache libc-dev libffi-dev gcc && \ 10 | pip install -r requirements.txt --no-cache-dir && \ 11 | apk del gcc libc-dev libffi-dev && \ 12 | addgroup webssh && \ 13 | adduser -Ss /bin/false -g webssh webssh && \ 14 | chown -R webssh:webssh /code 15 | 16 | EXPOSE 8888/tcp 17 | USER webssh 18 | CMD ["python", "run.py", "--delay=10", "--encoding=utf-8", "--fbidhttp=False", "--maxconn=20", "--origin=*", "--policy=warning", "--redirect=False", "--timeout=10", "--debug", "--xsrf=False", "--xheaders", "--wpintvl=1"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Shengdun Hua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | 3 | recursive-include tests * 4 | prune tests/__pycache__ 5 | prune tests/.pytest_cache 6 | 7 | recursive-include webssh * 8 | prune webssh/__pycache__ 9 | prune webssh/.pytest_cache 10 | 11 | global-exclude *.pyc 12 | global-exclude *.log 13 | global-exclude .coverage 14 | -------------------------------------------------------------------------------- /Picture1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/Picture1.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSSH 2 | ![webssh](./Picture1.gif) 3 | 4 | 为你的SSH连接需求提供安全便捷的管理方案 5 | 6 | ## ✨ 项目简介 7 | WebSSH 是一个基于 Web 的轻量级 SSH 管理工具,方便地在浏览器中进行安全的远程服务器管理。 8 | 9 | ## 🚀 一键云部署 10 | [![Run on CLAWCLOUD](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dwebssh) 11 | [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=docker&name=webssh&ports=8888;http;/&image=docker.io/cmliu/webssh) 12 | ## 🐳 Docker 一键部署 13 | ```shell 14 | docker run -d --name webssh --restart always -p 8888:8888 cmliu/webssh:latest 15 | ``` 16 | 17 | ## ⚙️ Docker `compose.yml` 部署 18 | ```yml 19 | version: '3' 20 | services: 21 | webssh: 22 | container_name: webssh 23 | image: cmliu/webssh:latest 24 | ports: 25 | - "8888:8888" 26 | restart: always 27 | network_mode: bridge 28 | ``` 29 | 30 | ## 🏗️ 手动部署 31 | 在克隆代码后,通过安装依赖并运行脚本即可快速启动项目: 32 | 33 | ```shell 34 | git clone https://github.com/cmliu/webssh 35 | cd webssh 36 | pip install -r requirements.txt && python run.py --delay=10 --encoding=utf-8 --fbidhttp=False --maxconn=20 --origin='*' --policy=warning --redirect=False --timeout=10 --port=8888 --debug --xsrf=False --xheaders --wpintvl=1 37 | ``` 38 | 39 | ## 💡 工作原理 40 | WebSSH 通过 WebSocket 与浏览器进行实时交互,并将请求转发给基于 Tornado 与 Paramiko 的后端,实现对 SSH 服务器的安全连接和交互。流程如下所示: 41 | ``` 42 | +---------+ http +--------+ ssh +-----------+ 43 | | browser | <==========> | webssh | <=======> | ssh server| 44 | +---------+ websocket +--------+ ssh +-----------+ 45 | ``` 46 | 这使得用户无需本地安装 SSH 客户端,即可通过网页方便快速地完成服务器管理操作。 47 | 48 | ## 🛠️ 更多资料 49 | - [部署到容器的教程](https://zelikk.blogspot.com/2023/10/huashengdun-webssh-codesandbox.html) 50 | - [部署到Hugging Face的教程 / 作者 Xiang xjfkkk](https://linux.do/t/topic/135264) 51 | - [部署到 Serv00 教程 / 作者 Xiang xjfkkk](https://linux.do/t/topic/211113) 52 | 53 | # 🙏 致谢 54 | [huashengdun](https://github.com/huashengdun/webssh)、[crazypeace](https://github.com/crazypeace/huashengdun-webssh)、[Mingyu](https://github.com/ymyuuu)、[ClawCloud](https://console.run.claw.cloud/signin?link=1DFUAGF6JA6R) -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | WebSSH 2 | ------ 3 | 4 | |Build Status| |codecov| |PyPI - Python Version| |PyPI| 5 | 6 | Introduction 7 | ~~~~~~~~~~~~ 8 | 9 | A simple web application to be used as an ssh client to connect to your 10 | ssh servers. It is written in Python, base on tornado, paramiko and 11 | xterm.js. 12 | 13 | Features 14 | ~~~~~~~~ 15 | 16 | - SSH password authentication supported, including empty password. 17 | - SSH public-key authentication supported, including DSA RSA ECDSA 18 | Ed25519 keys. 19 | - Encrypted keys supported. 20 | - Two-Factor Authentication (time-based one-time password) supported. 21 | - Fullscreen terminal supported. 22 | - Terminal window resizable. 23 | - Auto detect the ssh server's default encoding. 24 | - Modern browsers including Chrome, Firefox, Safari, Edge, Opera 25 | supported. 26 | 27 | Preview 28 | ~~~~~~~ 29 | 30 | |Login| |Terminal| 31 | 32 | How it works 33 | ~~~~~~~~~~~~ 34 | 35 | :: 36 | 37 | +---------+ http +--------+ ssh +-----------+ 38 | | browser | <==========> | webssh | <=======> | ssh server| 39 | +---------+ websocket +--------+ ssh +-----------+ 40 | 41 | Requirements 42 | ~~~~~~~~~~~~ 43 | 44 | - Python 3.8+ 45 | 46 | Quickstart 47 | ~~~~~~~~~~ 48 | 49 | 1. Install this app, run command ``pip install webssh`` 50 | 2. Start a webserver, run command ``wssh`` 51 | 3. Open your browser, navigate to ``127.0.0.1:8888`` 52 | 4. Input your data, submit the form. 53 | 54 | Server options 55 | ~~~~~~~~~~~~~~ 56 | 57 | .. code:: bash 58 | 59 | # start a http server with specified listen address and listen port 60 | wssh --address='2.2.2.2' --port=8000 61 | 62 | # start a https server, certfile and keyfile must be passed 63 | wssh --certfile='/path/to/cert.crt' --keyfile='/path/to/cert.key' 64 | 65 | # missing host key policy 66 | wssh --policy=reject 67 | 68 | # logging level 69 | wssh --logging=debug 70 | 71 | # log to file 72 | wssh --log-file-prefix=main.log 73 | 74 | # more options 75 | wssh --help 76 | 77 | Browser console 78 | ~~~~~~~~~~~~~~~ 79 | 80 | .. code:: javascript 81 | 82 | // connect to your ssh server 83 | wssh.connect(hostname, port, username, password, privatekey, passphrase, totp); 84 | 85 | // pass an object to wssh.connect 86 | var opts = { 87 | hostname: 'hostname', 88 | port: 'port', 89 | username: 'username', 90 | password: 'password', 91 | privatekey: 'the private key text', 92 | passphrase: 'passphrase', 93 | totp: 'totp' 94 | }; 95 | wssh.connect(opts); 96 | 97 | // without an argument, wssh will use the form data to connect 98 | wssh.connect(); 99 | 100 | // set a new encoding for client to use 101 | wssh.set_encoding(encoding); 102 | 103 | // reset encoding to use the default one 104 | wssh.reset_encoding(); 105 | 106 | // send a command to the server 107 | wssh.send('ls -l'); 108 | 109 | Custom Font 110 | ~~~~~~~~~~~ 111 | 112 | To use custom font, put your font file in the directory 113 | ``webssh/static/css/fonts/`` and restart the server. 114 | 115 | URL Arguments 116 | ~~~~~~~~~~~~~ 117 | 118 | Support passing arguments by url (query or fragment) like following 119 | examples: 120 | 121 | Passing form data (password must be encoded in base64, privatekey not 122 | supported) 123 | 124 | .. code:: bash 125 | 126 | http://localhost:8888/?hostname=xx&username=yy&password=str_base64_encoded 127 | 128 | Passing a terminal background color 129 | 130 | .. code:: bash 131 | 132 | http://localhost:8888/#bgcolor=green 133 | 134 | Passing a user defined title 135 | 136 | .. code:: bash 137 | 138 | http://localhost:8888/?title=my-ssh-server 139 | 140 | Passing an encoding 141 | 142 | .. code:: bash 143 | 144 | http://localhost:8888/#encoding=gbk 145 | 146 | Passing a command executed right after login 147 | 148 | .. code:: bash 149 | 150 | http://localhost:8888/?command=pwd 151 | 152 | Passing a terminal type 153 | 154 | .. code:: bash 155 | 156 | http://localhost:8888/?term=xterm-256color 157 | 158 | Use Docker 159 | ~~~~~~~~~~ 160 | 161 | Start up the app 162 | 163 | :: 164 | 165 | docker-compose up 166 | 167 | Tear down the app 168 | 169 | :: 170 | 171 | docker-compose down 172 | 173 | Tests 174 | ~~~~~ 175 | 176 | Requirements 177 | 178 | :: 179 | 180 | pip install pytest pytest-cov codecov flake8 mock 181 | 182 | Use unittest to run all tests 183 | 184 | :: 185 | 186 | python -m unittest discover tests 187 | 188 | Use pytest to run all tests 189 | 190 | :: 191 | 192 | python -m pytest tests 193 | 194 | Deployment 195 | ~~~~~~~~~~ 196 | 197 | Running behind an Nginx server 198 | 199 | .. code:: bash 200 | 201 | wssh --address='127.0.0.1' --port=8888 --policy=reject 202 | 203 | .. code:: nginx 204 | 205 | # Nginx config example 206 | location / { 207 | proxy_pass http://127.0.0.1:8888; 208 | proxy_http_version 1.1; 209 | proxy_read_timeout 300; 210 | proxy_set_header Upgrade $http_upgrade; 211 | proxy_set_header Connection "upgrade"; 212 | proxy_set_header Host $http_host; 213 | proxy_set_header X-Real-IP $remote_addr; 214 | proxy_set_header X-Real-PORT $remote_port; 215 | } 216 | 217 | Running as a standalone server 218 | 219 | .. code:: bash 220 | 221 | wssh --port=8080 --sslport=4433 --certfile='cert.crt' --keyfile='cert.key' --xheaders=False --policy=reject 222 | 223 | Tips 224 | ~~~~ 225 | 226 | - For whatever deployment choice you choose, don't forget to enable 227 | SSL. 228 | - By default plain http requests from a public network will be either 229 | redirected or blocked and being redirected takes precedence over 230 | being blocked. 231 | - Try to use reject policy as the missing host key policy along with 232 | your verified known\_hosts, this will prevent man-in-the-middle 233 | attacks. The idea is that it checks the system host keys 234 | file("~/.ssh/known\_hosts") and the application host keys 235 | file("./known\_hosts") in order, if the ssh server's hostname is not 236 | found or the key is not matched, the connection will be aborted. 237 | 238 | .. |Build Status| image:: https://travis-ci.org/huashengdun/webssh.svg?branch=master 239 | :target: https://travis-ci.org/huashengdun/webssh 240 | .. |codecov| image:: https://codecov.io/gh/huashengdun/webssh/branch/master/graph/badge.svg 241 | :target: https://codecov.io/gh/huashengdun/webssh 242 | .. |PyPI - Python Version| image:: https://img.shields.io/pypi/pyversions/webssh.svg 243 | .. |PyPI| image:: https://img.shields.io/pypi/v/webssh.svg 244 | .. |Login| image:: https://github.com/huashengdun/webssh/raw/master/preview/login.png 245 | .. |Terminal| image:: https://github.com/huashengdun/webssh/raw/master/preview/terminal.png 246 | 247 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "8888:8888" 7 | -------------------------------------------------------------------------------- /preview/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/preview/login.png -------------------------------------------------------------------------------- /preview/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/preview/terminal.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paramiko==3.0.0 2 | tornado==6.2.0 3 | webssh 4 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from webssh.main import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | 7 | [flake8] 8 | exclude = .git,build,dist,tests, __init__.py 9 | max-line-length = 79 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from setuptools import setup 3 | from webssh._version import __version__ as version 4 | 5 | 6 | with codecs.open('README.rst', encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | 10 | setup( 11 | name='webssh', 12 | version=version, 13 | description='Web based ssh client', 14 | long_description=long_description, 15 | author='Shengdun Hua', 16 | author_email='webmaster0115@gmail.com', 17 | url='https://github.com/huashengdun/webssh', 18 | packages=['webssh'], 19 | entry_points=''' 20 | [console_scripts] 21 | wssh = webssh.main:main 22 | ''', 23 | license='MIT', 24 | include_package_data=True, 25 | classifiers=[ 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | 'Programming Language :: Python :: 3.11', 32 | ], 33 | install_requires=[ 34 | 'tornado>=4.5.0', 35 | 'paramiko>=2.3.1', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/cert.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDYDCCAkigAwIBAgIJAPPORA/o2Zd4MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTgxMDE0MDgwNTQzWhcNMjExMDEzMDgwNTQzWjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEAvSFaffq6ExFCPN4cApRopGEqVIipAYb6Ky3VHVu4pW0tOdrdKafGGYkN 8 | GWQdsLV0AAzzxmCAPpXmmAx0m0mgtPaJp3iW8NUibkISxdEO/QJOA7y8O9iWhDdb 9 | l9ghjwPI5AwURQkDkXbcBBBzQksYDaYseL2NGDGXkKCUQQoLzV0H+SV3vCPrbOXH 10 | t50HKgKzEOGoT8LcI7BRCTXk1xTlK0b/4ylKUwKIsfNPH0a9RkukBjMFkpXG/2CV 11 | VWb89+TkMzQwhcpIVn6rUCJQW5pHVRYLACP32Zki7xPUJb9OfF7XDK54v6Cwo3Fi 12 | aZWxN6rYhnn8wRTufY3PYzv5f3XiZwIDAQABo1MwUTAdBgNVHQ4EFgQUq0kfpU/m 13 | WQwNk3ymwm7fuVwYhJ0wHwYDVR0jBBgwFoAUq0kfpU/mWQwNk3ymwm7fuVwYhJ0w 14 | DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAf2xudhAeOTUpNpw+ 15 | XZWLBXBKZXINd7PrUDgEG4bB0/0kYZN+T7bMJEtmv6+9t57y6jSni9sQzpbvT2tJ 16 | TrbZgwhDvyTm3mw5n5RpAB9ZK+lnMcasa5N4qSd6wmpXjkC+kcEs7oQ8PwgIf3xT 17 | /aGdoswNTWCz0W8vs8yRynLB4MKx1d20IMlDkfGu5n7wXhNK0ymcT8pa6iqEYl6X 18 | bhPVTlELl8bM/OKktFc42VXoRghLRnfl8yM/9t7HVHKfHXZrLpIdtEOvnKwtzX5r 19 | fBMs4IPa0OIPHGCcbLGT4rIbSvSaI8yOPA93G1XXbMF1VKdKyzdGjMS6aFKfbrhV 20 | lnaUOA== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /tests/data/cert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9IVp9+roTEUI8 3 | 3hwClGikYSpUiKkBhvorLdUdW7ilbS052t0pp8YZiQ0ZZB2wtXQADPPGYIA+leaY 4 | DHSbSaC09omneJbw1SJuQhLF0Q79Ak4DvLw72JaEN1uX2CGPA8jkDBRFCQORdtwE 5 | EHNCSxgNpix4vY0YMZeQoJRBCgvNXQf5JXe8I+ts5ce3nQcqArMQ4ahPwtwjsFEJ 6 | NeTXFOUrRv/jKUpTAoix808fRr1GS6QGMwWSlcb/YJVVZvz35OQzNDCFykhWfqtQ 7 | IlBbmkdVFgsAI/fZmSLvE9Qlv058XtcMrni/oLCjcWJplbE3qtiGefzBFO59jc9j 8 | O/l/deJnAgMBAAECggEAZSwcblvbgiuvVUQzk6W0PIrFzCa20dxUoxiHcocIRWYb 9 | 1WEhAhF/xVUtLrIBt++5N/W1yh8BO3mQuzGehxth3qwrguzdQcOiAX1S8YMeE3ZS 10 | KWmjABiim+PJGXdCrHCH3IYhqbRitkPw+jOalJH7MgH8tDIh8hlFTNa5t/kZyybW 11 | uGFbqF6OFmyHSDIPvjPALzSlmd5po+EywnA5oa3sObj4n5xuaFB2l/IaF3ix38vT 12 | geo517L15cCuAa7x42i1cAGn5H/hdeO/Dw+MGk+0sXRRPooCMBzKztxpsB+7kNhk 13 | jbsVHmTkE5UG/T7Uc0PsthZNjFwouPOrQQVUFYTnwQKBgQDwBvpmc9vX4gnADa7p 14 | L2lgMVo6KccPFeFr4DIAYmwS0Vl0sB2j6nPVEBg3PatGLKGNMCIlcj+A3z6KQ+4o 15 | n7pnekRwX+2+m3OPX4Rbw8c/+E0CiRPtmYp9BISKNgPoSRGsI6s/L3wzagsDsQ3v 16 | xhKCohvfyY8JwUEPX6Hosmu/UQKBgQDJt0/ihWn0g/2uOKnXlXthxvkXFoR45sO7 17 | lY/yoyJB+Z4yGAjJlbyra+5xnReqYyBnf34/2AoddjT45dPCaFucMInQFINdMGF1 18 | NeVNzC6xa/7jjbgwf4kGqHsLC85Mrq3wyK5hwhMmfEPmRs6w+CRzM/Q78Bsr5P/T 19 | zEa13jFINwKBgQC50L0ieUjVDKD9s9oXnWOXWz19T4BRtl+nco1i7M67lqQJCJo5 20 | njQD2ozUnwIrtjtuoLeeg56Ttr+krEf/3P+iQe4fjLPxXkiM0qYVoC9s311GvDXY 21 | N4gVllzA3mYR+hcbSxW0OZ+N8ecK+ZNPbug/hx3LFi+MnrYuH5upGA7/sQKBgCRk 22 | nlUQHP2wkqRMNNhgb9JEQ8yWk2/8snO1mDL+m7+reY8wJuW3zkJfRrXY0dw75izG 23 | I9EA+VI3cXc2f+4jReP4HeUczlaR1AOBpc1TeVkpUuNbPlABsocw/oIPrzjGiztV 24 | +aBJk4ruAJIbVE85ddoTFY161Gwm9MERqfBGFj4hAoGAN/ry0KC9/QkLkuPjs3uL 25 | AU3xjBJt1SMB7KZq1yt8mBo8M4q/E3ulynBK7G3f+hS2aj7OAhU4IcPRPGqjsLO1 26 | dZTIOMeVyOAr0TAaioCCIyvf8hEjA7cXddnWBJYi3WiUpOc6J0uINoSlrAX2UXtw 27 | /Aq5PmJKn4D4a75f+ue2Sw8= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/data/fonts/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/tests/data/fonts/.gitignore -------------------------------------------------------------------------------- /tests/data/fonts/fake-font: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmliu/webssh/d8a7898a3329f7e6c670248d6dfcf21ff2eb00f7/tests/data/fonts/fake-font -------------------------------------------------------------------------------- /tests/data/known_hosts_example: -------------------------------------------------------------------------------- 1 | 192.168.1.199 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINwZGQmNFADnAAlm5uFLQTrdxqpNxHdgg4JPbB3sR2kr 2 | -------------------------------------------------------------------------------- /tests/data/known_hosts_example2: -------------------------------------------------------------------------------- 1 | 192.168.1.196 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINwZGQmNFADnAAlm5uFLQTrdxqpNxHdgg4JPbB3sR2kr 2 | -------------------------------------------------------------------------------- /tests/data/known_hosts_example3: -------------------------------------------------------------------------------- 1 | 192.168.1.196 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINwZGQmNFADnAAlm5uFLQTrdxqpNxHdgg4JPbB3sR2jr 2 | -------------------------------------------------------------------------------- /tests/data/test_ed25519.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXwAAAKhjwAdrY8AH 4 | awAAAAtzc2gtZWQyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXw 5 | AAAEA9tGQi2IrprbOSbDCF+RmAHd6meNSXBUQ2ekKXm4/8xnr1K9komH/1WBIvQbbtvnFV 6 | hryd62EfcgRFuLRiokNfAAAAI2FsZXhfZ2F5bm9yQEFsZXhzLU1hY0Jvb2stQWlyLmxvY2 7 | FsAQI= 8 | -----END OPENSSH PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /tests/data/test_ed25519_password.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABDaKD4ac7 3 | kieb+UfXaLaw68AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIOQn7fjND5ozMSV3 4 | CvbEtIdT73hWCMRjzS/lRdUDw50xAAAAsE8kLGyYBnl9ihJNqv378y6mO3SkzrDbWXOnK6 5 | ij0vnuTAvcqvWHAnyu6qBbplu/W2m55ZFeAItgaEcV2/V76sh/sAKlERqrLFyXylN0xoOW 6 | NU5+zU08aTlbSKGmeNUU2xE/xfJq12U9XClIRuVUkUpYANxNPbmTRpVrbD3fgXMhK97Jrb 7 | DEn8ca1IqMPiYmd/hpe5+tq3OxyRljXjCUFWTnqkp9VvUdzSTdSGZHsW9i 8 | -----END OPENSSH PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /tests/data/test_known_hosts: -------------------------------------------------------------------------------- 1 | [127.0.0.1]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINwZGQmNFADnAAlm5uFLQTrdxqpNxHdgg4JPbB3sR2kr 2 | -------------------------------------------------------------------------------- /tests/data/test_new_dsa.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH 3 | NzAAAAgQC5Y5rQ1EN+eWQUFv/9K/DLfPgjGC0mwyqvKsKyv6RLpKLc0vi0VDj8lY0WUcuG 4 | CzdYnhIOSa9aB0buGe10gIjU2vAxkhqv1yaR+Zuj3dLDHQk6jpAAgNHciKlQSf1zho/seL 5 | 7nehYq/waXfU8/iJuXqywQgqpMLfaHOnIl/tPLGQAAABUArINMjWcrsmEgLmzf6k+sroko 6 | 5GkAAACAMQsRQjOtQGQA8/XI7vOWnEMCVntwt1Xi4RsLH5+4GpUMUcm4CvqjfFfSF4CufH 7 | pjlywFhrAC2/ouQIpGJPGToWotk7dt5zWckGX5DscMiRVON7fxdpUMn16IO6DdUctXlWa9 8 | SY+NdfRESKoUCjgH5nlM8k7N2MwCK5phHHkoPu8AAACADgxrRWeNqX3gmZUM1qhrDO0mOH 9 | oHJFrBuvJCdQ6+S1GvjuBI0rNm225+gcaAhia9k/LGk8NwCbWG1FbpesuNaNFt/FxS9LVS 10 | qEaZoXtKuY+CUCn1BfBWF97/u0oMPwanXKIJEAhU81f5TXZM8Ui7OEIyTx1t9qgva+5/gF 11 | cL48kAAAHoLtDYCy7Q2AsAAAAHc3NoLWRzcwAAAIEAuWOa0NRDfnlkFBb//Svwy3z4Ixgt 12 | JsMqryrCsr+kS6Si3NL4tFQ4/JWNFlHLhgs3WJ4SDkmvWgdG7hntdICI1NrwMZIar9cmkf 13 | mbo93Swx0JOo6QAIDR3IipUEn9c4aP7Hi+53oWKv8Gl31PP4ibl6ssEIKqTC32hzpyJf7T 14 | yxkAAAAVAKyDTI1nK7JhIC5s3+pPrK6JKORpAAAAgDELEUIzrUBkAPP1yO7zlpxDAlZ7cL 15 | dV4uEbCx+fuBqVDFHJuAr6o3xX0heArnx6Y5csBYawAtv6LkCKRiTxk6FqLZO3bec1nJBl 16 | +Q7HDIkVTje38XaVDJ9eiDug3VHLV5VmvUmPjXX0REiqFAo4B+Z5TPJOzdjMAiuaYRx5KD 17 | 7vAAAAgA4Ma0Vnjal94JmVDNaoawztJjh6ByRawbryQnUOvktRr47gSNKzZttufoHGgIYm 18 | vZPyxpPDcAm1htRW6XrLjWjRbfxcUvS1UqhGmaF7SrmPglAp9QXwVhfe/7tKDD8Gp1yiCR 19 | AIVPNX+U12TPFIuzhCMk8dbfaoL2vuf4BXC+PJAAAAFBVcac1iVzrWVnLglRZRenUhlKLr 20 | AAAADHNoZW5nQHNlcnZlcgECAwQFBgc= 21 | -----END OPENSSH PRIVATE KEY----- 22 | -------------------------------------------------------------------------------- /tests/data/test_new_rsa_password.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABASFMDZtr 3 | vMq0+bs9xBVRMOAAAAEAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQCpYgFiRc6d 4 | etTng/gKoHzfZrgsr+0dqsfVkrsTAl/w+2OsZbR6MCbcY94fEcE7WMTWSYUY2qv+35nlQn 5 | MT/8Q8Y8TTMbcQLIOaNhLQ2dFH8wn2e7+DbUT8giOOEICBjdUZx3tEH7PcFTzQ9ivHVIkb 6 | Rk8UHbj3vznvBvNEgQK+jj0ZI3+deOOFlPbnq9R3dJNgdVXAEnSt0cEfjteJQwT4PcaA2N 7 | fQvQAQtspC0EfEixvBH+yJsvjPDZwnYyejVGbGwKMdqAJJVka4QRkCJNoi5eyngDj/pzC7 8 | OhGeqNwlG+D28Zz885HXIZ5eEKYNy9YJlff1WlWH8/+1fb9eVdGEXd2/fpzc/+r2QW88aX 9 | L3bg2o46qswi+5F/yYbw8AOPCq1P62ZbsVxxWTYvG947AvxfH9ycZoOItizLofOluBELQV 10 | 0P/0ooa0kPJpWQXuTAY7YSzo4vgw1F+O+8b1g33mWftUu6OHp7Rb2N3yRUiGVq9dVYeFhR 11 | 8ycyFPWjoNvwMAAAWAfnTLRACzZl9T9m7oZXtRn/OFKsr/Z8mKfkeTb4PQ+cFT/Bi2adNq 12 | 2JTsBhfGXAXiKLVVOBgBRmY5c+x0oWyrC1agoOEWkz1LhnKlJ2ETbmJBfDeRsMy5COQDmh 13 | Wnfj8noLzv59+MrPcIEfHSdC4Rai2JgFH54m5G5vaGR6SGbQ27E1ZPYnzzG9qrEB2UY30S 14 | 1gCs8G4ppX/clIVq0eToKAHseV7UG/FDwuaiPOvk61pyUjefj+bexggZxUOJANdB5pWfl7 15 | BnEM3q9nD4QF74yrWZL38897Izku9l2Iupn64DMVs2+T/9WsfR7kDgJDoL2Noa/57w4ien 16 | Wt6WtKBnISmh9Bm5zbRG5fhPEMtCgrV3TAPgzj1VQ8Vy91D16CnWucqBpdDys46gUodiVZ 17 | Z6idCV6z24hHIJc7joR2mCNmqitCGcyrf4cO8tzug1DZVMeSkKSqL85oH9u/EOR/uWWNQi 18 | GAlehn8gmmlborYsLybau68EfyHSwYJ8XaLrELDfvM9L1CHDDacJ4svFa93r0y380Fek5P 19 | CqOLH4IqhpLHWWRoWSr23AjO6p0ZihrHzSveIzmuuTNr6uJmFt76jPKcpmLycCKhD8gKtk 20 | ZRjh+y5mEruTg/BJixCWhbl88rPYRSGNGjR9e91esw8Yj8BGYEvbvhkG0pQQpv937dbJuh 21 | n+CtnpvGr+8Mhw+mB2OW2c38XaAouwugLSoWV16xcwWx3z0ez0EAyeWjHev2XxjW5bigWg 22 | edmDPiYN+1I+OmG7d5NctKqNABb0qpwavL1uRJO96cC1drwucu5aTBrMRv1HlDQpsPHSRf 23 | u4FVruLE0wDaL2saowkZDJF5GoxjMdpzOpeVmjREuU3NwCrQr8t/AvDxzXl4x8BZ3jJTwe 24 | RA0yTGwSAZDzeN3KV2FLn+0K7xB+XvKqtKR5/IOlGviCt2w73nJpReAuSgMk95M/9imm5J 25 | r/AEcmkXKUT8gjPIT6B1xs44nnWvyf+CZreUZthAjYAjXn4ncKT51WX8q1dUuCKt9XQC7b 26 | pKH20WrP7BB/AoPPyaKtRbDBIy3Y9YA8KDsYoR9kC+hqIttL5IWxXwc15HzkU4fdKLQ4n1 27 | VTfzaz5Ns2gsfsSAYdyJKZ8JkP/tHR2bFN7m1rWqfzL8hrGv+BF/+rR7/3+BDOD0aZCep6 28 | u6mO4OD9hEuOP2rK5EVjJAoON7nYmjdfDpXRmp/p2f0Y+pA4R7CN+4xnel1gxlE7tBdQ7z 29 | Zu2O+NPToHXGLhzwUKUIqVhYb5cwdMIzaFQwyvOTyjNVMH0AqcsF2VuDWkgSqALg1CCSz3 30 | 7Vinx6/tyPYZ1kHm+j0dNijSdvHZrwsmvxPfYspzB7K+Vi5cNsOw6pQGIBgBTBIU09FqB+ 31 | MRBfNmLfVgVYsiU1jz/s/7H3J8DTNIC1XS4LRUXVlwddGSP/dXLgO6EJX3OvdduBD04HSZ 32 | wWggXDgWo1snhB8O2w6YSk6ocd801gPesebXGBWm+54oirWrpDr3E9y2RS7oaDFAMUV6rV 33 | IG/gc4rEFUNKX+0RwKJyArmYYJOhYgfoH0fEs01OKs6NzcsknXKVLPAXUaXV77nGlc4xsa 34 | G62+K3rLdaMFSWf/TFaIrl2Bma3p4tx993hsjNQewRhnrWdyEqP8CLcKq8Wc/fl4LlytWA 35 | PhjtjWxAp0RQKvjEu4Ul0SbFoiC+hbh+pWhVoQjPTXZePBWgI1M8CHX4fvcoRk0Ay1VMwx 36 | AZzHoZZl6v4arok4/nqwv5kYo7HhRbJrPBbNAJcGkE0Hnbh/4DxtcOLsSgwACTw03qavji 37 | wvu8wv0L5oQ6Q0H6LCUMQl/2eTuUt9uVtFXWRPmYolqmIKR5ZejYACI3XVyfaYJR6SuSx8 38 | PR/8/w== 39 | -----END OPENSSH PRIVATE KEY----- 40 | -------------------------------------------------------------------------------- /tests/data/test_rsa.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICWgIBAAKBgQDTj1bqB4WmayWNPB+8jVSYpZYk80Ujvj680pOTh2bORBjbIAyz 3 | oWGW+GUjzKxTiiPvVmxFgx5wdsFvF03v34lEVVhMpouqPAYQ15N37K/ir5XY+9m/ 4 | d8ufMCkjeXsQkKqFbAlQcnWMCRnOoPHS3I4vi6hmnDDeeYTSRvfLbW0fhwIBIwKB 5 | gBIiOqZYaoqbeD9OS9z2K9KR2atlTxGxOJPXiP4ESqP3NVScWNwyZ3NXHpyrJLa0 6 | EbVtzsQhLn6rF+TzXnOlcipFvjsem3iYzCpuChfGQ6SovTcOjHV9z+hnpXvQ/fon 7 | soVRZY65wKnF7IAoUwTmJS9opqgrN6kRgCd3DASAMd1bAkEA96SBVWFt/fJBNJ9H 8 | tYnBKZGw0VeHOYmVYbvMSstssn8un+pQpUm9vlG/bp7Oxd/m+b9KWEh2xPfv6zqU 9 | avNwHwJBANqzGZa/EpzF4J8pGti7oIAPUIDGMtfIcmqNXVMckrmzQ2vTfqtkEZsA 10 | 4rE1IERRyiJQx6EJsz21wJmGV9WJQ5kCQQDwkS0uXqVdFzgHO6S++tjmjYcxwr3g 11 | H0CoFYSgbddOT6miqRskOQF3DZVkJT3kyuBgU2zKygz52ukQZMqxCb1fAkASvuTv 12 | qfpH87Qq5kQhNKdbbwbmd2NxlNabazPijWuphGTdW0VfJdWfklyS2Kr+iqrs/5wV 13 | HhathJt636Eg7oIjAkA8ht3MQ+XSl9yIJIS8gVpbPxSw5OMfw0PjVE7tBdQruiSc 14 | nvuQES5C9BMHjF39LZiGH1iLQy7FgdHyoP+eodI7 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /tests/data/test_rsa_password.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: DES-EDE3-CBC,DAA422E8A5A8EFB7 4 | 5 | +nssHGmWl91IcmGiE6DdCIqGvAP04tuLh60wLjWBvdjtF9CjztPnF57xe+6pBk7o 6 | YgF/Ry3ik9ZV9rHNcRXifDKM9crxtYlpUlkM2C0SP89sXaO0P1Q1yCnrtZUwDIKO 7 | BNV8et5X7+AGMFsy/nmv0NFMrbpoG03Dppsloecd29NTRlIXwxHRFyHxy6BdEib/ 8 | Dn0mEVbwg3dTvKrd/sODWR9hRwpDGM9nkEbUNJCh7vMwFKkIZZF8yqFvmGckuO5C 9 | HZkDJ6RkEDYrSZJAavQaiOPF5bu3cHughRfnrIKVrQuTTDiWjwX9Ny8e4p4k7dy7 10 | rLpbPhtxUOUbpOF7T1QxljDi1Tcq3Ebk3kN/ZLPRFnDrJfyUx+m9BXmAa78Wxs/l 11 | KaS8DTkYykd3+EGOeJFjZg2bvgqil4V+5JIt/+MQ5pZ/ui7i4GcH2bvZyGAbrXzP 12 | 3LipSAdN5RG+fViLe3HUtfCx4ZAgtU78TWJrLk2FwKQGglFxKLnswp+IKZb09rZV 13 | uxmG4pPLUnH+mMYdiy5ugzj+5C8iZ0/IstpHVmO6GWROfedpJ82eMztTOtdhfMep 14 | 8Z3HwAwkDtksL7Gq9klb0Wq5+uRlBWetixddAvnmqXNzYhaANWcAF/2a2Hz06Rb0 15 | e6pe/g0Ek5KV+6YI+D+oEblG0Sr+d4NtxtDTmIJKNVkmzlhI2s53bHp6txCb5JWJ 16 | S8mKLPBBBzaNXYd3odDvGXguuxUntWSsD11KyR6B9DXMIfWQW5dT7hp5kTMGlXWJ 17 | lD2hYab13DCCuAkwVTdpzhHYLZyxLYoSu05W6z8SAOs= 18 | -----END RSA PRIVATE KEY----- 19 | -------------------------------------------------------------------------------- /tests/data/user_rsa_key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQDI7iK3d8eWYZlYloat94c5VjtFY7c/0zuGl8C7uMnZ3t6i2G99 3 | 66hEW0nCFSZkOW5F0XKEVj+EUCHvo8koYC6wiohAqWQnEwIoOoh7GSAcB8gP/qaq 4 | +adIl/Rvlby/mHakj+y05LBND6nFWHAn1y1gOFFKUXSJNRZPXSFy47gqzwIBIwKB 5 | gQCbANjz7q/pCXZLp1Hz6tYHqOvlEmjK1iabB1oqafrMpJ0eibUX/u+FMHq6StR5 6 | M5413BaDWHokPdEJUnabfWXXR3SMlBUKrck0eAer1O8m78yxu3OEdpRk+znVo4DL 7 | guMeCdJB/qcF0kEsx+Q8HP42MZU1oCmk3PbfXNFwaHbWuwJBAOQ/ry/hLD7AqB8x 8 | DmCM82A9E59ICNNlHOhxpJoh6nrNTPCsBAEu/SmqrL8mS6gmbRKUaya5Lx1pkxj2 9 | s/kWOokCQQDhXCcYXjjWiIfxhl6Rlgkk1vmI0l6785XSJNv4P7pXjGmShXfIzroh 10 | S8uWK3tL0GELY7+UAKDTUEVjjQdGxYSXAkEA3bo1JzKCwJ3lJZ1ebGuqmADRO6UP 11 | 40xH977aadfN1mEI6cusHmgpISl0nG5YH7BMsvaT+bs1FUH8m+hXDzoqOwJBAK3Z 12 | X/za+KV/REya2z0b+GzgWhkXUGUa/owrEBdHGriQ47osclkUgPUdNqcLmaDilAF4 13 | 1Z4PHPrI5RJIONAx+JECQQC/fChqjBgFpk6iJ+BOdSexQpgfxH/u/457W10Y43HR 14 | soS+8btbHqjQkowQ/2NTlUfWvqIlfxs6ZbFsIp/HrhZL 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /tests/sshserver.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import random 3 | import socket 4 | # import sys 5 | import threading 6 | # import traceback 7 | import paramiko 8 | 9 | from binascii import hexlify 10 | from tests.utils import make_tests_data_path 11 | 12 | 13 | # setup logging 14 | paramiko.util.log_to_file(make_tests_data_path('sshserver.log')) 15 | 16 | host_key = paramiko.RSAKey(filename=make_tests_data_path('test_rsa.key')) 17 | # host_key = paramiko.DSSKey(filename='test_dss.key') 18 | 19 | print('Read key: ' + hexlify(host_key.get_fingerprint()).decode('utf-8')) 20 | 21 | banner = u'\r\n\u6b22\u8fce\r\n' 22 | event_timeout = 5 23 | 24 | 25 | class Server(paramiko.ServerInterface): 26 | # 'data' is the output of base64.b64encode(key) 27 | # (using the "user_rsa_key" files) 28 | data = (b'AAAAB3NzaC1yc2EAAAABIwAAAIEAyO4it3fHlmGZWJaGrfeHOVY7RWO3P9M7hp' 29 | b'fAu7jJ2d7eothvfeuoRFtJwhUmZDluRdFyhFY/hFAh76PJKGAusIqIQKlkJxMC' 30 | b'KDqIexkgHAfID/6mqvmnSJf0b5W8v5h2pI/stOSwTQ+pxVhwJ9ctYDhRSlF0iT' 31 | b'UWT10hcuO4Ks8=') 32 | good_pub_key = paramiko.RSAKey(data=base64.decodebytes(data)) 33 | 34 | commands = [ 35 | b'$SHELL -ilc "locale charmap"', 36 | b'$SHELL -ic "locale charmap"' 37 | ] 38 | encodings = ['UTF-8', 'GBK', 'UTF-8\r\n', 'GBK\r\n'] 39 | 40 | def __init__(self, encodings=[]): 41 | self.shell_event = threading.Event() 42 | self.exec_event = threading.Event() 43 | self.cmd_to_enc = self.get_cmd2enc(encodings) 44 | self.password_verified = False 45 | self.key_verified = False 46 | 47 | def get_cmd2enc(self, encodings): 48 | n = len(self.commands) 49 | while len(encodings) < n: 50 | encodings.append(random.choice(self.encodings)) 51 | return dict(zip(self.commands, encodings[0:n])) 52 | 53 | def check_channel_request(self, kind, chanid): 54 | if kind == 'session': 55 | return paramiko.OPEN_SUCCEEDED 56 | return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED 57 | 58 | def check_auth_password(self, username, password): 59 | print('Auth attempt with username: {!r} & password: {!r}'.format(username, password)) # noqa 60 | if (username in ['robey', 'bar', 'foo']) and (password == 'foo'): 61 | return paramiko.AUTH_SUCCESSFUL 62 | return paramiko.AUTH_FAILED 63 | 64 | def check_auth_publickey(self, username, key): 65 | print('Auth attempt with username: {!r} & key: {!r}'.format(username, hexlify(key.get_fingerprint()).decode('utf-8'))) # noqa 66 | if (username in ['robey', 'keyonly']) and (key == self.good_pub_key): 67 | return paramiko.AUTH_SUCCESSFUL 68 | if username == 'pkey2fa' and key == self.good_pub_key: 69 | self.key_verified = True 70 | return paramiko.AUTH_PARTIALLY_SUCCESSFUL 71 | return paramiko.AUTH_FAILED 72 | 73 | def check_auth_interactive(self, username, submethods): 74 | if username in ['pass2fa', 'pkey2fa']: 75 | self.username = username 76 | prompt = 'Verification code: ' if self.password_verified else 'Password: ' # noqa 77 | print(username, prompt) 78 | return paramiko.InteractiveQuery('', '', prompt) 79 | return paramiko.AUTH_FAILED 80 | 81 | def check_auth_interactive_response(self, responses): 82 | if self.username in ['pass2fa', 'pkey2fa']: 83 | if not self.password_verified: 84 | if responses[0] == 'password': 85 | print('password verified') 86 | self.password_verified = True 87 | if self.username == 'pkey2fa': 88 | return self.check_auth_interactive(self.username, '') 89 | else: 90 | print('wrong password: {}'.format(responses[0])) 91 | return paramiko.AUTH_FAILED 92 | else: 93 | if responses[0] == 'passcode': 94 | print('totp verified') 95 | return paramiko.AUTH_SUCCESSFUL 96 | else: 97 | print('wrong totp: {}'.format(responses[0])) 98 | return paramiko.AUTH_FAILED 99 | else: 100 | return paramiko.AUTH_FAILED 101 | 102 | def get_allowed_auths(self, username): 103 | if username == 'keyonly': 104 | return 'publickey' 105 | if username == 'pass2fa': 106 | return 'keyboard-interactive' 107 | if username == 'pkey2fa': 108 | if not self.key_verified: 109 | return 'publickey' 110 | else: 111 | return 'keyboard-interactive' 112 | return 'password,publickey' 113 | 114 | def check_channel_exec_request(self, channel, command): 115 | if command not in self.commands: 116 | ret = False 117 | else: 118 | ret = True 119 | self.encoding = self.cmd_to_enc[command] 120 | channel.send(self.encoding) 121 | channel.shutdown(1) 122 | self.exec_event.set() 123 | return ret 124 | 125 | def check_channel_shell_request(self, channel): 126 | self.shell_event.set() 127 | return True 128 | 129 | def check_channel_pty_request(self, channel, term, width, height, 130 | pixelwidth, pixelheight, modes): 131 | return True 132 | 133 | def check_channel_window_change_request(self, channel, width, height, 134 | pixelwidth, pixelheight): 135 | channel.send('resized') 136 | return True 137 | 138 | 139 | def run_ssh_server(port=2200, running=True, encodings=[]): 140 | # now connect 141 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 142 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 143 | sock.bind(('127.0.0.1', port)) 144 | sock.listen(100) 145 | 146 | while running: 147 | client, addr = sock.accept() 148 | print('Got a connection!') 149 | 150 | t = paramiko.Transport(client) 151 | t.load_server_moduli() 152 | t.add_server_key(host_key) 153 | server = Server(encodings) 154 | try: 155 | t.start_server(server=server) 156 | except Exception as e: 157 | print(e) 158 | continue 159 | 160 | # wait for auth 161 | chan = t.accept(2) 162 | if chan is None: 163 | print('*** No channel.') 164 | continue 165 | 166 | username = t.get_username() 167 | print('{} Authenticated!'.format(username)) 168 | 169 | server.shell_event.wait(timeout=event_timeout) 170 | if not server.shell_event.is_set(): 171 | print('*** Client never asked for a shell.') 172 | continue 173 | 174 | server.exec_event.wait(timeout=event_timeout) 175 | if not server.exec_event.is_set(): 176 | print('*** Client never asked for a command.') 177 | continue 178 | 179 | # chan.send('\r\n\r\nWelcome!\r\n\r\n') 180 | print(server.encoding) 181 | try: 182 | banner_encoded = banner.encode(server.encoding) 183 | except (ValueError, LookupError): 184 | continue 185 | 186 | chan.send(banner_encoded) 187 | if username == 'bar': 188 | msg = chan.recv(1024) 189 | chan.send(msg) 190 | elif username == 'foo': 191 | lst = [] 192 | while True: 193 | msg = chan.recv(32 * 1024) 194 | lst.append(msg) 195 | if msg.endswith(b'\r\n\r\n'): 196 | break 197 | data = b''.join(lst) 198 | while data: 199 | s = chan.send(data) 200 | data = data[s:] 201 | else: 202 | chan.close() 203 | t.close() 204 | client.close() 205 | 206 | try: 207 | sock.close() 208 | except Exception: 209 | pass 210 | 211 | 212 | if __name__ == '__main__': 213 | run_ssh_server() 214 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import threading 4 | import tornado.websocket 5 | import tornado.gen 6 | 7 | from tornado.testing import AsyncHTTPTestCase 8 | from tornado.httpclient import HTTPError 9 | from tornado.options import options 10 | from tests.sshserver import run_ssh_server, banner, Server 11 | from tests.utils import encode_multipart_formdata, read_file, make_tests_data_path # noqa 12 | from webssh import handler 13 | from webssh.main import make_app, make_handlers 14 | from webssh.settings import ( 15 | get_app_settings, get_server_settings, max_body_size 16 | ) 17 | from webssh.utils import to_str 18 | from webssh.worker import clients 19 | 20 | try: 21 | from urllib.parse import urlencode 22 | except ImportError: 23 | from urllib import urlencode 24 | 25 | 26 | swallow_http_errors = handler.swallow_http_errors 27 | server_encodings = {e.strip() for e in Server.encodings} 28 | 29 | 30 | class TestAppBase(AsyncHTTPTestCase): 31 | 32 | def get_httpserver_options(self): 33 | return get_server_settings(options) 34 | 35 | def assert_response(self, bstr, response): 36 | if swallow_http_errors: 37 | self.assertEqual(response.code, 200) 38 | self.assertIn(bstr, response.body) 39 | else: 40 | self.assertEqual(response.code, 400) 41 | self.assertIn(b'Bad Request', response.body) 42 | 43 | def assert_status_in(self, status, data): 44 | self.assertIsNone(data['encoding']) 45 | self.assertIsNone(data['id']) 46 | self.assertIn(status, data['status']) 47 | 48 | def assert_status_equal(self, status, data): 49 | self.assertIsNone(data['encoding']) 50 | self.assertIsNone(data['id']) 51 | self.assertEqual(status, data['status']) 52 | 53 | def assert_status_none(self, data): 54 | self.assertIsNotNone(data['encoding']) 55 | self.assertIsNotNone(data['id']) 56 | self.assertIsNone(data['status']) 57 | 58 | def fetch_request(self, url, method='GET', body='', headers={}, sync=True): 59 | if not sync and url.startswith('/'): 60 | url = self.get_url(url) 61 | 62 | if isinstance(body, dict): 63 | body = urlencode(body) 64 | 65 | if not headers: 66 | headers = self.headers 67 | else: 68 | headers.update(self.headers) 69 | 70 | client = self if sync else self.get_http_client() 71 | return client.fetch(url, method=method, body=body, headers=headers) 72 | 73 | def sync_post(self, url, body, headers={}): 74 | return self.fetch_request(url, 'POST', body, headers) 75 | 76 | def async_post(self, url, body, headers={}): 77 | return self.fetch_request(url, 'POST', body, headers, sync=False) 78 | 79 | 80 | class TestAppBasic(TestAppBase): 81 | 82 | running = [True] 83 | sshserver_port = 2200 84 | body = 'hostname=127.0.0.1&port={}&_xsrf=yummy&username=robey&password=foo'.format(sshserver_port) # noqa 85 | headers = {'Cookie': '_xsrf=yummy'} 86 | 87 | def get_app(self): 88 | self.body_dict = { 89 | 'hostname': '127.0.0.1', 90 | 'port': str(self.sshserver_port), 91 | 'username': 'robey', 92 | 'password': '', 93 | '_xsrf': 'yummy' 94 | } 95 | loop = self.io_loop 96 | options.debug = False 97 | options.policy = random.choice(['warning', 'autoadd']) 98 | options.hostfile = '' 99 | options.syshostfile = '' 100 | options.tdstream = '' 101 | options.delay = 0.1 102 | app = make_app(make_handlers(loop, options), get_app_settings(options)) 103 | return app 104 | 105 | @classmethod 106 | def setUpClass(cls): 107 | print('='*20) 108 | t = threading.Thread( 109 | target=run_ssh_server, args=(cls.sshserver_port, cls.running) 110 | ) 111 | t.setDaemon(True) 112 | t.start() 113 | 114 | @classmethod 115 | def tearDownClass(cls): 116 | cls.running.pop() 117 | print('='*20) 118 | 119 | def test_app_with_invalid_form_for_missing_argument(self): 120 | response = self.fetch('/') 121 | self.assertEqual(response.code, 200) 122 | 123 | body = 'port=7000&username=admin&password&_xsrf=yummy' 124 | response = self.sync_post('/', body) 125 | self.assert_response(b'Missing argument hostname', response) 126 | 127 | body = 'hostname=127.0.0.1&port=7000&password&_xsrf=yummy' 128 | response = self.sync_post('/', body) 129 | self.assert_response(b'Missing argument username', response) 130 | 131 | body = 'hostname=&port=&username=&password&_xsrf=yummy' 132 | response = self.sync_post('/', body) 133 | self.assert_response(b'Missing value hostname', response) 134 | 135 | body = 'hostname=127.0.0.1&port=7000&username=&password&_xsrf=yummy' 136 | response = self.sync_post('/', body) 137 | self.assert_response(b'Missing value username', response) 138 | 139 | def test_app_with_invalid_form_for_invalid_value(self): 140 | body = 'hostname=127.0.0&port=22&username=&password&_xsrf=yummy' 141 | response = self.sync_post('/', body) 142 | self.assert_response(b'Invalid hostname', response) 143 | 144 | body = 'hostname=http://www.googe.com&port=22&username=&password&_xsrf=yummy' # noqa 145 | response = self.sync_post('/', body) 146 | self.assert_response(b'Invalid hostname', response) 147 | 148 | body = 'hostname=127.0.0.1&port=port&username=&password&_xsrf=yummy' 149 | response = self.sync_post('/', body) 150 | self.assert_response(b'Invalid port', response) 151 | 152 | body = 'hostname=127.0.0.1&port=70000&username=&password&_xsrf=yummy' 153 | response = self.sync_post('/', body) 154 | self.assert_response(b'Invalid port', response) 155 | 156 | def test_app_with_wrong_hostname_ip(self): 157 | body = 'hostname=127.0.0.2&port=2200&username=admin&_xsrf=yummy' 158 | response = self.sync_post('/', body) 159 | self.assertEqual(response.code, 200) 160 | self.assertIn(b'Unable to connect to', response.body) 161 | 162 | def test_app_with_wrong_hostname_domain(self): 163 | body = 'hostname=xxxxxxxxxxxx&port=2200&username=admin&_xsrf=yummy' 164 | response = self.sync_post('/', body) 165 | self.assertEqual(response.code, 200) 166 | self.assertIn(b'Unable to connect to', response.body) 167 | 168 | def test_app_with_wrong_port(self): 169 | body = 'hostname=127.0.0.1&port=7000&username=admin&_xsrf=yummy' 170 | response = self.sync_post('/', body) 171 | self.assertEqual(response.code, 200) 172 | self.assertIn(b'Unable to connect to', response.body) 173 | 174 | def test_app_with_wrong_credentials(self): 175 | response = self.sync_post('/', self.body + 's') 176 | self.assert_status_in('Authentication failed.', json.loads(to_str(response.body))) # noqa 177 | 178 | def test_app_with_correct_credentials(self): 179 | response = self.sync_post('/', self.body) 180 | self.assert_status_none(json.loads(to_str(response.body))) 181 | 182 | def test_app_with_correct_credentials_but_with_no_port(self): 183 | default_port = handler.DEFAULT_PORT 184 | handler.DEFAULT_PORT = self.sshserver_port 185 | 186 | # with no port value 187 | body = self.body.replace(str(self.sshserver_port), '') 188 | response = self.sync_post('/', body) 189 | self.assert_status_none(json.loads(to_str(response.body))) 190 | 191 | # with no port argument 192 | body = body.replace('port=&', '') 193 | response = self.sync_post('/', body) 194 | self.assert_status_none(json.loads(to_str(response.body))) 195 | 196 | handler.DEFAULT_PORT = default_port 197 | 198 | @tornado.testing.gen_test 199 | def test_app_with_correct_credentials_timeout(self): 200 | url = self.get_url('/') 201 | response = yield self.async_post(url, self.body) 202 | data = json.loads(to_str(response.body)) 203 | self.assert_status_none(data) 204 | 205 | url = url.replace('http', 'ws') 206 | ws_url = url + 'ws?id=' + data['id'] 207 | yield tornado.gen.sleep(options.delay + 0.1) 208 | ws = yield tornado.websocket.websocket_connect(ws_url) 209 | msg = yield ws.read_message() 210 | self.assertIsNone(msg) 211 | self.assertEqual(ws.close_reason, 'Websocket authentication failed.') 212 | 213 | @tornado.testing.gen_test 214 | def test_app_with_correct_credentials_but_ip_not_matched(self): 215 | url = self.get_url('/') 216 | response = yield self.async_post(url, self.body) 217 | data = json.loads(to_str(response.body)) 218 | self.assert_status_none(data) 219 | 220 | clients = handler.clients 221 | handler.clients = {} 222 | url = url.replace('http', 'ws') 223 | ws_url = url + 'ws?id=' + data['id'] 224 | ws = yield tornado.websocket.websocket_connect(ws_url) 225 | msg = yield ws.read_message() 226 | self.assertIsNone(msg) 227 | self.assertEqual(ws.close_reason, 'Websocket authentication failed.') 228 | handler.clients = clients 229 | 230 | @tornado.testing.gen_test 231 | def test_app_with_correct_credentials_user_robey(self): 232 | url = self.get_url('/') 233 | response = yield self.async_post(url, self.body) 234 | data = json.loads(to_str(response.body)) 235 | self.assert_status_none(data) 236 | 237 | url = url.replace('http', 'ws') 238 | ws_url = url + 'ws?id=' + data['id'] 239 | ws = yield tornado.websocket.websocket_connect(ws_url) 240 | msg = yield ws.read_message() 241 | self.assertEqual(to_str(msg, data['encoding']), banner) 242 | ws.close() 243 | 244 | @tornado.testing.gen_test 245 | def test_app_with_correct_credentials_but_without_id_argument(self): 246 | url = self.get_url('/') 247 | response = yield self.async_post(url, self.body) 248 | data = json.loads(to_str(response.body)) 249 | self.assert_status_none(data) 250 | 251 | url = url.replace('http', 'ws') 252 | ws_url = url + 'ws' 253 | ws = yield tornado.websocket.websocket_connect(ws_url) 254 | msg = yield ws.read_message() 255 | self.assertIsNone(msg) 256 | self.assertIn('Missing argument id', ws.close_reason) 257 | 258 | @tornado.testing.gen_test 259 | def test_app_with_correct_credentials_but_empty_id(self): 260 | url = self.get_url('/') 261 | response = yield self.async_post(url, self.body) 262 | data = json.loads(to_str(response.body)) 263 | self.assert_status_none(data) 264 | 265 | url = url.replace('http', 'ws') 266 | ws_url = url + 'ws?id=' 267 | ws = yield tornado.websocket.websocket_connect(ws_url) 268 | msg = yield ws.read_message() 269 | self.assertIsNone(msg) 270 | self.assertIn('Missing value id', ws.close_reason) 271 | 272 | @tornado.testing.gen_test 273 | def test_app_with_correct_credentials_but_wrong_id(self): 274 | url = self.get_url('/') 275 | response = yield self.async_post(url, self.body) 276 | data = json.loads(to_str(response.body)) 277 | self.assert_status_none(data) 278 | 279 | url = url.replace('http', 'ws') 280 | ws_url = url + 'ws?id=1' + data['id'] 281 | ws = yield tornado.websocket.websocket_connect(ws_url) 282 | msg = yield ws.read_message() 283 | self.assertIsNone(msg) 284 | self.assertIn('Websocket authentication failed', ws.close_reason) 285 | 286 | @tornado.testing.gen_test 287 | def test_app_with_correct_credentials_user_bar(self): 288 | body = self.body.replace('robey', 'bar') 289 | url = self.get_url('/') 290 | response = yield self.async_post(url, body) 291 | data = json.loads(to_str(response.body)) 292 | self.assert_status_none(data) 293 | 294 | url = url.replace('http', 'ws') 295 | ws_url = url + 'ws?id=' + data['id'] 296 | ws = yield tornado.websocket.websocket_connect(ws_url) 297 | msg = yield ws.read_message() 298 | self.assertEqual(to_str(msg, data['encoding']), banner) 299 | 300 | # messages below will be ignored silently 301 | yield ws.write_message('hello') 302 | yield ws.write_message('"hello"') 303 | yield ws.write_message('[hello]') 304 | yield ws.write_message(json.dumps({'resize': []})) 305 | yield ws.write_message(json.dumps({'resize': {}})) 306 | yield ws.write_message(json.dumps({'resize': 'ab'})) 307 | yield ws.write_message(json.dumps({'resize': ['a', 'b']})) 308 | yield ws.write_message(json.dumps({'resize': {'a': 1, 'b': 2}})) 309 | yield ws.write_message(json.dumps({'resize': [100]})) 310 | yield ws.write_message(json.dumps({'resize': [100]*10})) 311 | yield ws.write_message(json.dumps({'resize': [-1, -1]})) 312 | yield ws.write_message(json.dumps({'data': [1]})) 313 | yield ws.write_message(json.dumps({'data': (1,)})) 314 | yield ws.write_message(json.dumps({'data': {'a': 2}})) 315 | yield ws.write_message(json.dumps({'data': 1})) 316 | yield ws.write_message(json.dumps({'data': 2.1})) 317 | yield ws.write_message(json.dumps({'key-non-existed': 'hello'})) 318 | # end - those just for testing webssh websocket stablity 319 | 320 | yield ws.write_message(json.dumps({'resize': [79, 23]})) 321 | msg = yield ws.read_message() 322 | self.assertEqual(b'resized', msg) 323 | 324 | yield ws.write_message(json.dumps({'data': 'bye'})) 325 | msg = yield ws.read_message() 326 | self.assertEqual(b'bye', msg) 327 | ws.close() 328 | 329 | @tornado.testing.gen_test 330 | def test_app_auth_with_valid_pubkey_by_urlencoded_form(self): 331 | url = self.get_url('/') 332 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 333 | self.body_dict.update(privatekey=privatekey) 334 | response = yield self.async_post(url, self.body_dict) 335 | data = json.loads(to_str(response.body)) 336 | self.assert_status_none(data) 337 | 338 | url = url.replace('http', 'ws') 339 | ws_url = url + 'ws?id=' + data['id'] 340 | ws = yield tornado.websocket.websocket_connect(ws_url) 341 | msg = yield ws.read_message() 342 | self.assertEqual(to_str(msg, data['encoding']), banner) 343 | ws.close() 344 | 345 | @tornado.testing.gen_test 346 | def test_app_auth_with_valid_pubkey_by_multipart_form(self): 347 | url = self.get_url('/') 348 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 349 | files = [('privatekey', 'user_rsa_key', privatekey)] 350 | content_type, body = encode_multipart_formdata(self.body_dict.items(), 351 | files) 352 | headers = { 353 | 'Content-Type': content_type, 'content-length': str(len(body)) 354 | } 355 | response = yield self.async_post(url, body, headers=headers) 356 | data = json.loads(to_str(response.body)) 357 | self.assert_status_none(data) 358 | 359 | url = url.replace('http', 'ws') 360 | ws_url = url + 'ws?id=' + data['id'] 361 | ws = yield tornado.websocket.websocket_connect(ws_url) 362 | msg = yield ws.read_message() 363 | self.assertEqual(to_str(msg, data['encoding']), banner) 364 | ws.close() 365 | 366 | @tornado.testing.gen_test 367 | def test_app_auth_with_invalid_pubkey_for_user_robey(self): 368 | url = self.get_url('/') 369 | privatekey = 'h' * 1024 370 | files = [('privatekey', 'user_rsa_key', privatekey)] 371 | content_type, body = encode_multipart_formdata(self.body_dict.items(), 372 | files) 373 | headers = { 374 | 'Content-Type': content_type, 'content-length': str(len(body)) 375 | } 376 | 377 | if swallow_http_errors: 378 | response = yield self.async_post(url, body, headers=headers) 379 | self.assertIn(b'Invalid key', response.body) 380 | else: 381 | with self.assertRaises(HTTPError) as ctx: 382 | yield self.async_post(url, body, headers=headers) 383 | self.assertIn('Bad Request', ctx.exception.message) 384 | 385 | @tornado.testing.gen_test 386 | def test_app_auth_with_pubkey_exceeds_key_max_size(self): 387 | url = self.get_url('/') 388 | privatekey = 'h' * (handler.PrivateKey.max_length + 1) 389 | files = [('privatekey', 'user_rsa_key', privatekey)] 390 | content_type, body = encode_multipart_formdata(self.body_dict.items(), 391 | files) 392 | headers = { 393 | 'Content-Type': content_type, 'content-length': str(len(body)) 394 | } 395 | if swallow_http_errors: 396 | response = yield self.async_post(url, body, headers=headers) 397 | self.assertIn(b'Invalid key', response.body) 398 | else: 399 | with self.assertRaises(HTTPError) as ctx: 400 | yield self.async_post(url, body, headers=headers) 401 | self.assertIn('Bad Request', ctx.exception.message) 402 | 403 | @tornado.testing.gen_test 404 | def test_app_auth_with_pubkey_cannot_be_decoded_by_multipart_form(self): 405 | url = self.get_url('/') 406 | privatekey = 'h' * 1024 407 | files = [('privatekey', 'user_rsa_key', privatekey)] 408 | content_type, body = encode_multipart_formdata(self.body_dict.items(), 409 | files) 410 | body = body.encode('utf-8') 411 | # added some gbk bytes to the privatekey, make it cannot be decoded 412 | body = body[:-100] + b'\xb4\xed\xce\xf3' + body[-100:] 413 | headers = { 414 | 'Content-Type': content_type, 'content-length': str(len(body)) 415 | } 416 | if swallow_http_errors: 417 | response = yield self.async_post(url, body, headers=headers) 418 | self.assertIn(b'Invalid unicode', response.body) 419 | else: 420 | with self.assertRaises(HTTPError) as ctx: 421 | yield self.async_post(url, body, headers=headers) 422 | self.assertIn('Bad Request', ctx.exception.message) 423 | 424 | def test_app_post_form_with_large_body_size_by_multipart_form(self): 425 | privatekey = 'h' * (2 * max_body_size) 426 | files = [('privatekey', 'user_rsa_key', privatekey)] 427 | content_type, body = encode_multipart_formdata(self.body_dict.items(), 428 | files) 429 | headers = { 430 | 'Content-Type': content_type, 'content-length': str(len(body)) 431 | } 432 | response = self.sync_post('/', body, headers=headers) 433 | self.assertIn(response.code, [400, 599]) 434 | 435 | def test_app_post_form_with_large_body_size_by_urlencoded_form(self): 436 | privatekey = 'h' * (2 * max_body_size) 437 | body = self.body + '&privatekey=' + privatekey 438 | response = self.sync_post('/', body) 439 | self.assertIn(response.code, [400, 599]) 440 | 441 | @tornado.testing.gen_test 442 | def test_app_with_user_keyonly_for_bad_authentication_type(self): 443 | self.body_dict.update(username='keyonly', password='foo') 444 | response = yield self.async_post('/', self.body_dict) 445 | self.assertEqual(response.code, 200) 446 | self.assert_status_in('Bad authentication type', json.loads(to_str(response.body))) # noqa 447 | 448 | @tornado.testing.gen_test 449 | def test_app_with_user_pass2fa_with_correct_passwords(self): 450 | self.body_dict.update(username='pass2fa', password='password', 451 | totp='passcode') 452 | response = yield self.async_post('/', self.body_dict) 453 | self.assertEqual(response.code, 200) 454 | data = json.loads(to_str(response.body)) 455 | self.assert_status_none(data) 456 | 457 | @tornado.testing.gen_test 458 | def test_app_with_user_pass2fa_with_wrong_pkey_correct_passwords(self): 459 | url = self.get_url('/') 460 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 461 | self.body_dict.update(username='pass2fa', password='password', 462 | privatekey=privatekey, totp='passcode') 463 | response = yield self.async_post(url, self.body_dict) 464 | data = json.loads(to_str(response.body)) 465 | self.assert_status_none(data) 466 | 467 | @tornado.testing.gen_test 468 | def test_app_with_user_pkey2fa_with_correct_passwords(self): 469 | url = self.get_url('/') 470 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 471 | self.body_dict.update(username='pkey2fa', password='password', 472 | privatekey=privatekey, totp='passcode') 473 | response = yield self.async_post(url, self.body_dict) 474 | data = json.loads(to_str(response.body)) 475 | self.assert_status_none(data) 476 | 477 | @tornado.testing.gen_test 478 | def test_app_with_user_pkey2fa_with_wrong_password(self): 479 | url = self.get_url('/') 480 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 481 | self.body_dict.update(username='pkey2fa', password='wrongpassword', 482 | privatekey=privatekey, totp='passcode') 483 | response = yield self.async_post(url, self.body_dict) 484 | data = json.loads(to_str(response.body)) 485 | self.assert_status_in('Authentication failed', data) 486 | 487 | @tornado.testing.gen_test 488 | def test_app_with_user_pkey2fa_with_wrong_passcode(self): 489 | url = self.get_url('/') 490 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 491 | self.body_dict.update(username='pkey2fa', password='password', 492 | privatekey=privatekey, totp='wrongpasscode') 493 | response = yield self.async_post(url, self.body_dict) 494 | data = json.loads(to_str(response.body)) 495 | self.assert_status_in('Authentication failed', data) 496 | 497 | @tornado.testing.gen_test 498 | def test_app_with_user_pkey2fa_with_empty_passcode(self): 499 | url = self.get_url('/') 500 | privatekey = read_file(make_tests_data_path('user_rsa_key')) 501 | self.body_dict.update(username='pkey2fa', password='password', 502 | privatekey=privatekey, totp='') 503 | response = yield self.async_post(url, self.body_dict) 504 | data = json.loads(to_str(response.body)) 505 | self.assert_status_in('Need a verification code', data) 506 | 507 | 508 | class OtherTestBase(TestAppBase): 509 | sshserver_port = 3300 510 | headers = {'Cookie': '_xsrf=yummy'} 511 | debug = False 512 | policy = None 513 | xsrf = True 514 | hostfile = '' 515 | syshostfile = '' 516 | tdstream = '' 517 | maxconn = 20 518 | origin = 'same' 519 | encodings = [] 520 | body = { 521 | 'hostname': '127.0.0.1', 522 | 'port': '', 523 | 'username': 'robey', 524 | 'password': 'foo', 525 | '_xsrf': 'yummy' 526 | } 527 | 528 | def get_app(self): 529 | self.body.update(port=str(self.sshserver_port)) 530 | loop = self.io_loop 531 | options.debug = self.debug 532 | options.xsrf = self.xsrf 533 | options.policy = self.policy if self.policy else random.choice(['warning', 'autoadd']) # noqa 534 | options.hostfile = self.hostfile 535 | options.syshostfile = self.syshostfile 536 | options.tdstream = self.tdstream 537 | options.maxconn = self.maxconn 538 | options.origin = self.origin 539 | app = make_app(make_handlers(loop, options), get_app_settings(options)) 540 | return app 541 | 542 | def setUp(self): 543 | print('='*20) 544 | self.running = True 545 | OtherTestBase.sshserver_port += 1 546 | 547 | t = threading.Thread( 548 | target=run_ssh_server, 549 | args=(self.sshserver_port, self.running, self.encodings) 550 | ) 551 | t.setDaemon(True) 552 | t.start() 553 | super(OtherTestBase, self).setUp() 554 | 555 | def tearDown(self): 556 | self.running = False 557 | print('='*20) 558 | super(OtherTestBase, self).tearDown() 559 | 560 | 561 | class TestAppInDebugMode(OtherTestBase): 562 | 563 | debug = True 564 | 565 | def assert_response(self, bstr, response): 566 | if swallow_http_errors: 567 | self.assertEqual(response.code, 200) 568 | self.assertIn(bstr, response.body) 569 | else: 570 | self.assertEqual(response.code, 500) 571 | self.assertIn(b'Uncaught exception', response.body) 572 | 573 | def test_server_error_for_post_method(self): 574 | body = dict(self.body, error='raise') 575 | response = self.sync_post('/', body) 576 | self.assert_response(b'"status": "Internal Server Error"', response) 577 | 578 | def test_html(self): 579 | response = self.fetch('/', method='GET') 580 | self.assertIn(b'novalidate>', response.body) 581 | 582 | 583 | class TestAppWithLargeBuffer(OtherTestBase): 584 | 585 | @tornado.testing.gen_test 586 | def test_app_for_sending_message_with_large_size(self): 587 | url = self.get_url('/') 588 | response = yield self.async_post(url, dict(self.body, username='foo')) 589 | data = json.loads(to_str(response.body)) 590 | self.assert_status_none(data) 591 | 592 | url = url.replace('http', 'ws') 593 | ws_url = url + 'ws?id=' + data['id'] 594 | ws = yield tornado.websocket.websocket_connect(ws_url) 595 | msg = yield ws.read_message() 596 | self.assertEqual(to_str(msg, data['encoding']), banner) 597 | 598 | send = 'h' * (64 * 1024) + '\r\n\r\n' 599 | yield ws.write_message(json.dumps({'data': send})) 600 | lst = [] 601 | while True: 602 | msg = yield ws.read_message() 603 | lst.append(msg) 604 | if msg.endswith(b'\r\n\r\n'): 605 | break 606 | recv = b''.join(lst).decode(data['encoding']) 607 | self.assertEqual(send, recv) 608 | ws.close() 609 | 610 | 611 | class TestAppWithRejectPolicy(OtherTestBase): 612 | 613 | policy = 'reject' 614 | hostfile = make_tests_data_path('known_hosts_example') 615 | 616 | @tornado.testing.gen_test 617 | def test_app_with_hostname_not_in_hostkeys(self): 618 | response = yield self.async_post('/', self.body) 619 | data = json.loads(to_str(response.body)) 620 | message = 'Connection to {}:{} is not allowed.'.format(self.body['hostname'], self.sshserver_port) # noqa 621 | self.assertEqual(message, data['status']) 622 | 623 | 624 | class TestAppWithBadHostKey(OtherTestBase): 625 | 626 | policy = random.choice(['warning', 'autoadd', 'reject']) 627 | hostfile = make_tests_data_path('test_known_hosts') 628 | 629 | def setUp(self): 630 | self.sshserver_port = 2222 631 | super(TestAppWithBadHostKey, self).setUp() 632 | 633 | @tornado.testing.gen_test 634 | def test_app_with_bad_host_key(self): 635 | response = yield self.async_post('/', self.body) 636 | data = json.loads(to_str(response.body)) 637 | self.assertEqual('Bad host key.', data['status']) 638 | 639 | 640 | class TestAppWithTrustedStream(OtherTestBase): 641 | tdstream = '127.0.0.2' 642 | 643 | def test_with_forbidden_get_request(self): 644 | response = self.fetch('/', method='GET') 645 | self.assertEqual(response.code, 403) 646 | self.assertIn('Forbidden', response.error.message) 647 | 648 | def test_with_forbidden_post_request(self): 649 | response = self.sync_post('/', self.body) 650 | self.assertEqual(response.code, 403) 651 | self.assertIn('Forbidden', response.error.message) 652 | 653 | def test_with_forbidden_put_request(self): 654 | response = self.fetch_request('/', method='PUT', body=self.body) 655 | self.assertEqual(response.code, 403) 656 | self.assertIn('Forbidden', response.error.message) 657 | 658 | 659 | class TestAppNotFoundHandler(OtherTestBase): 660 | 661 | custom_headers = handler.MixinHandler.custom_headers 662 | 663 | def test_with_not_found_get_request(self): 664 | response = self.fetch('/pathnotfound', method='GET') 665 | self.assertEqual(response.code, 404) 666 | self.assertEqual( 667 | response.headers['Server'], self.custom_headers['Server'] 668 | ) 669 | self.assertIn(b'404: Not Found', response.body) 670 | 671 | def test_with_not_found_post_request(self): 672 | response = self.sync_post('/pathnotfound', self.body) 673 | self.assertEqual(response.code, 404) 674 | self.assertEqual( 675 | response.headers['Server'], self.custom_headers['Server'] 676 | ) 677 | self.assertIn(b'404: Not Found', response.body) 678 | 679 | def test_with_not_found_put_request(self): 680 | response = self.fetch_request('/pathnotfound', method='PUT', 681 | body=self.body) 682 | self.assertEqual(response.code, 404) 683 | self.assertEqual( 684 | response.headers['Server'], self.custom_headers['Server'] 685 | ) 686 | self.assertIn(b'404: Not Found', response.body) 687 | 688 | 689 | class TestAppWithHeadRequest(OtherTestBase): 690 | 691 | def test_with_index_path(self): 692 | response = self.fetch('/', method='HEAD') 693 | self.assertEqual(response.code, 200) 694 | 695 | def test_with_ws_path(self): 696 | response = self.fetch('/ws', method='HEAD') 697 | self.assertEqual(response.code, 405) 698 | 699 | def test_with_not_found_path(self): 700 | response = self.fetch('/notfound', method='HEAD') 701 | self.assertEqual(response.code, 404) 702 | 703 | 704 | class TestAppWithPutRequest(OtherTestBase): 705 | 706 | xsrf = False 707 | 708 | @tornado.testing.gen_test 709 | def test_app_with_method_not_supported(self): 710 | with self.assertRaises(HTTPError) as ctx: 711 | yield self.fetch_request('/', 'PUT', self.body, sync=False) 712 | self.assertIn('Method Not Allowed', ctx.exception.message) 713 | 714 | 715 | class TestAppWithTooManyConnections(OtherTestBase): 716 | 717 | maxconn = 1 718 | 719 | def setUp(self): 720 | clients.clear() 721 | super(TestAppWithTooManyConnections, self).setUp() 722 | 723 | @tornado.testing.gen_test 724 | def test_app_with_too_many_connections(self): 725 | clients['127.0.0.1'] = {'fake_worker_id': None} 726 | 727 | url = self.get_url('/') 728 | response = yield self.async_post(url, self.body) 729 | data = json.loads(to_str(response.body)) 730 | self.assertEqual('Too many live connections.', data['status']) 731 | 732 | clients['127.0.0.1'].clear() 733 | response = yield self.async_post(url, self.body) 734 | self.assert_status_none(json.loads(to_str(response.body))) 735 | 736 | 737 | class TestAppWithCrossOriginOperation(OtherTestBase): 738 | 739 | origin = 'http://www.example.com' 740 | 741 | @tornado.testing.gen_test 742 | def test_app_with_wrong_event_origin(self): 743 | body = dict(self.body, _origin='localhost') 744 | response = yield self.async_post('/', body) 745 | self.assert_status_equal('Cross origin operation is not allowed.', json.loads(to_str(response.body))) # noqa 746 | 747 | @tornado.testing.gen_test 748 | def test_app_with_wrong_header_origin(self): 749 | headers = dict(Origin='localhost') 750 | response = yield self.async_post('/', self.body, headers=headers) 751 | self.assert_status_equal('Cross origin operation is not allowed.', json.loads(to_str(response.body)), ) # noqa 752 | 753 | @tornado.testing.gen_test 754 | def test_app_with_correct_event_origin(self): 755 | body = dict(self.body, _origin=self.origin) 756 | response = yield self.async_post('/', body) 757 | self.assert_status_none(json.loads(to_str(response.body))) 758 | self.assertIsNone(response.headers.get('Access-Control-Allow-Origin')) 759 | 760 | @tornado.testing.gen_test 761 | def test_app_with_correct_header_origin(self): 762 | headers = dict(Origin=self.origin) 763 | response = yield self.async_post('/', self.body, headers=headers) 764 | self.assert_status_none(json.loads(to_str(response.body))) 765 | self.assertEqual( 766 | response.headers.get('Access-Control-Allow-Origin'), self.origin 767 | ) 768 | 769 | 770 | class TestAppWithBadEncoding(OtherTestBase): 771 | 772 | encodings = [u'\u7f16\u7801'] 773 | 774 | @tornado.testing.gen_test 775 | def test_app_with_a_bad_encoding(self): 776 | response = yield self.async_post('/', self.body) 777 | dic = json.loads(to_str(response.body)) 778 | self.assert_status_none(dic) 779 | self.assertIn(dic['encoding'], server_encodings) 780 | 781 | 782 | class TestAppWithUnknownEncoding(OtherTestBase): 783 | 784 | encodings = [u'\u7f16\u7801', u'UnknownEncoding'] 785 | 786 | @tornado.testing.gen_test 787 | def test_app_with_a_unknown_encoding(self): 788 | response = yield self.async_post('/', self.body) 789 | self.assert_status_none(json.loads(to_str(response.body))) 790 | dic = json.loads(to_str(response.body)) 791 | self.assert_status_none(dic) 792 | self.assertEqual(dic['encoding'], 'utf-8') 793 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | import paramiko 4 | 5 | from tornado.httputil import HTTPServerRequest 6 | from tornado.options import options 7 | from tests.utils import read_file, make_tests_data_path 8 | from webssh import handler 9 | from webssh import worker 10 | from webssh.handler import ( 11 | IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient 12 | ) 13 | 14 | try: 15 | from unittest.mock import Mock 16 | except ImportError: 17 | from mock import Mock 18 | 19 | 20 | class TestMixinHandler(unittest.TestCase): 21 | 22 | def test_is_forbidden(self): 23 | mhandler = MixinHandler() 24 | handler.redirecting = True 25 | options.fbidhttp = True 26 | 27 | context = Mock( 28 | address=('8.8.8.8', 8888), 29 | trusted_downstream=['127.0.0.1'], 30 | _orig_protocol='http' 31 | ) 32 | hostname = '4.4.4.4' 33 | self.assertTrue(mhandler.is_forbidden(context, hostname)) 34 | 35 | context = Mock( 36 | address=('8.8.8.8', 8888), 37 | trusted_downstream=[], 38 | _orig_protocol='http' 39 | ) 40 | hostname = 'www.google.com' 41 | self.assertEqual(mhandler.is_forbidden(context, hostname), False) 42 | 43 | context = Mock( 44 | address=('8.8.8.8', 8888), 45 | trusted_downstream=[], 46 | _orig_protocol='http' 47 | ) 48 | hostname = '4.4.4.4' 49 | self.assertTrue(mhandler.is_forbidden(context, hostname)) 50 | 51 | context = Mock( 52 | address=('192.168.1.1', 8888), 53 | trusted_downstream=[], 54 | _orig_protocol='http' 55 | ) 56 | hostname = 'www.google.com' 57 | self.assertIsNone(mhandler.is_forbidden(context, hostname)) 58 | 59 | options.fbidhttp = False 60 | self.assertIsNone(mhandler.is_forbidden(context, hostname)) 61 | 62 | hostname = '4.4.4.4' 63 | self.assertIsNone(mhandler.is_forbidden(context, hostname)) 64 | 65 | handler.redirecting = False 66 | self.assertIsNone(mhandler.is_forbidden(context, hostname)) 67 | 68 | context._orig_protocol = 'https' 69 | self.assertIsNone(mhandler.is_forbidden(context, hostname)) 70 | 71 | def test_get_redirect_url(self): 72 | mhandler = MixinHandler() 73 | hostname = 'www.example.com' 74 | uri = '/' 75 | port = 443 76 | 77 | self.assertEqual( 78 | mhandler.get_redirect_url(hostname, port, uri=uri), 79 | 'https://www.example.com/' 80 | ) 81 | 82 | port = 4433 83 | self.assertEqual( 84 | mhandler.get_redirect_url(hostname, port, uri), 85 | 'https://www.example.com:4433/' 86 | ) 87 | 88 | def test_get_client_addr(self): 89 | mhandler = MixinHandler() 90 | client_addr = ('8.8.8.8', 8888) 91 | context_addr = ('127.0.0.1', 1234) 92 | options.xheaders = True 93 | 94 | mhandler.context = Mock(address=context_addr) 95 | mhandler.get_real_client_addr = lambda: None 96 | self.assertEqual(mhandler.get_client_addr(), context_addr) 97 | 98 | mhandler.context = Mock(address=context_addr) 99 | mhandler.get_real_client_addr = lambda: client_addr 100 | self.assertEqual(mhandler.get_client_addr(), client_addr) 101 | 102 | options.xheaders = False 103 | mhandler.context = Mock(address=context_addr) 104 | mhandler.get_real_client_addr = lambda: client_addr 105 | self.assertEqual(mhandler.get_client_addr(), context_addr) 106 | 107 | def test_get_real_client_addr(self): 108 | x_forwarded_for = '1.1.1.1' 109 | x_forwarded_port = 1111 110 | x_real_ip = '2.2.2.2' 111 | x_real_port = 2222 112 | fake_port = 65535 113 | 114 | mhandler = MixinHandler() 115 | mhandler.request = HTTPServerRequest(uri='/') 116 | mhandler.request.remote_ip = x_forwarded_for 117 | 118 | self.assertIsNone(mhandler.get_real_client_addr()) 119 | 120 | mhandler.request.headers.add('X-Forwarded-For', x_forwarded_for) 121 | self.assertEqual(mhandler.get_real_client_addr(), 122 | (x_forwarded_for, fake_port)) 123 | 124 | mhandler.request.headers.add('X-Forwarded-Port', fake_port + 1) 125 | self.assertEqual(mhandler.get_real_client_addr(), 126 | (x_forwarded_for, fake_port)) 127 | 128 | mhandler.request.headers['X-Forwarded-Port'] = x_forwarded_port 129 | self.assertEqual(mhandler.get_real_client_addr(), 130 | (x_forwarded_for, x_forwarded_port)) 131 | 132 | mhandler.request.remote_ip = x_real_ip 133 | 134 | mhandler.request.headers.add('X-Real-Ip', x_real_ip) 135 | self.assertEqual(mhandler.get_real_client_addr(), 136 | (x_real_ip, fake_port)) 137 | 138 | mhandler.request.headers.add('X-Real-Port', fake_port + 1) 139 | self.assertEqual(mhandler.get_real_client_addr(), 140 | (x_real_ip, fake_port)) 141 | 142 | mhandler.request.headers['X-Real-Port'] = x_real_port 143 | self.assertEqual(mhandler.get_real_client_addr(), 144 | (x_real_ip, x_real_port)) 145 | 146 | 147 | class TestPrivateKey(unittest.TestCase): 148 | 149 | def get_pk_obj(self, fname, password=None): 150 | key = read_file(make_tests_data_path(fname)) 151 | return PrivateKey(key, password=password, filename=fname) 152 | 153 | def _test_with_encrypted_key(self, fname, password, klass): 154 | pk = self.get_pk_obj(fname, password='') 155 | with self.assertRaises(InvalidValueError) as ctx: 156 | pk.get_pkey_obj() 157 | self.assertIn('Need a passphrase', str(ctx.exception)) 158 | 159 | pk = self.get_pk_obj(fname, password='wrongpass') 160 | with self.assertRaises(InvalidValueError) as ctx: 161 | pk.get_pkey_obj() 162 | self.assertIn('wrong passphrase', str(ctx.exception)) 163 | 164 | pk = self.get_pk_obj(fname, password=password) 165 | self.assertIsInstance(pk.get_pkey_obj(), klass) 166 | 167 | def test_class_with_invalid_key_length(self): 168 | key = u'a' * (PrivateKey.max_length + 1) 169 | 170 | with self.assertRaises(InvalidValueError) as ctx: 171 | PrivateKey(key) 172 | self.assertIn('Invalid key length', str(ctx.exception)) 173 | 174 | def test_get_pkey_obj_with_invalid_key(self): 175 | key = u'a b c' 176 | fname = 'abc' 177 | 178 | pk = PrivateKey(key, filename=fname) 179 | with self.assertRaises(InvalidValueError) as ctx: 180 | pk.get_pkey_obj() 181 | self.assertIn('Invalid key {}'.format(fname), str(ctx.exception)) 182 | 183 | def test_get_pkey_obj_with_plain_rsa_key(self): 184 | pk = self.get_pk_obj('test_rsa.key') 185 | self.assertIsInstance(pk.get_pkey_obj(), paramiko.RSAKey) 186 | 187 | def test_get_pkey_obj_with_plain_ed25519_key(self): 188 | pk = self.get_pk_obj('test_ed25519.key') 189 | self.assertIsInstance(pk.get_pkey_obj(), paramiko.Ed25519Key) 190 | 191 | def test_get_pkey_obj_with_encrypted_rsa_key(self): 192 | fname = 'test_rsa_password.key' 193 | password = 'television' 194 | self._test_with_encrypted_key(fname, password, paramiko.RSAKey) 195 | 196 | def test_get_pkey_obj_with_encrypted_ed25519_key(self): 197 | fname = 'test_ed25519_password.key' 198 | password = 'abc123' 199 | self._test_with_encrypted_key(fname, password, paramiko.Ed25519Key) 200 | 201 | def test_get_pkey_obj_with_encrypted_new_rsa_key(self): 202 | fname = 'test_new_rsa_password.key' 203 | password = '123456' 204 | self._test_with_encrypted_key(fname, password, paramiko.RSAKey) 205 | 206 | def test_get_pkey_obj_with_plain_new_dsa_key(self): 207 | pk = self.get_pk_obj('test_new_dsa.key') 208 | self.assertIsInstance(pk.get_pkey_obj(), paramiko.DSSKey) 209 | 210 | def test_parse_name(self): 211 | key = u'-----BEGIN PRIVATE KEY-----' 212 | pk = PrivateKey(key) 213 | name, _ = pk.parse_name(pk.iostr, pk.tag_to_name) 214 | self.assertIsNone(name) 215 | 216 | key = u'-----BEGIN xxx PRIVATE KEY-----' 217 | pk = PrivateKey(key) 218 | name, _ = pk.parse_name(pk.iostr, pk.tag_to_name) 219 | self.assertIsNone(name) 220 | 221 | key = u'-----BEGIN RSA PRIVATE KEY-----' 222 | pk = PrivateKey(key) 223 | name, _ = pk.parse_name(pk.iostr, pk.tag_to_name) 224 | self.assertIsNone(name) 225 | 226 | key = u'-----BEGIN RSA PRIVATE KEY-----' 227 | pk = PrivateKey(key) 228 | name, _ = pk.parse_name(pk.iostr, pk.tag_to_name) 229 | self.assertIsNone(name) 230 | 231 | key = u'-----BEGIN RSA PRIVATE KEY-----' 232 | pk = PrivateKey(key) 233 | name, _ = pk.parse_name(pk.iostr, pk.tag_to_name) 234 | self.assertIsNone(name) 235 | 236 | for tag, to_name in PrivateKey.tag_to_name.items(): 237 | key = u'-----BEGIN {} PRIVATE KEY----- \r\n'.format(tag) 238 | pk = PrivateKey(key) 239 | name, length = pk.parse_name(pk.iostr, pk.tag_to_name) 240 | self.assertEqual(name, to_name) 241 | self.assertEqual(length, len(key)) 242 | 243 | 244 | class TestWsockHandler(unittest.TestCase): 245 | 246 | def test_check_origin(self): 247 | request = HTTPServerRequest(uri='/') 248 | obj = Mock(spec=WsockHandler, request=request) 249 | 250 | obj.origin_policy = 'same' 251 | request.headers['Host'] = 'www.example.com:4433' 252 | origin = 'https://www.example.com:4433' 253 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 254 | 255 | origin = 'https://www.example.com' 256 | self.assertFalse(WsockHandler.check_origin(obj, origin)) 257 | 258 | obj.origin_policy = 'primary' 259 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 260 | 261 | origin = 'https://blog.example.com' 262 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 263 | 264 | origin = 'https://blog.example.org' 265 | self.assertFalse(WsockHandler.check_origin(obj, origin)) 266 | 267 | origin = 'https://blog.example.org' 268 | obj.origin_policy = {'https://blog.example.org'} 269 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 270 | 271 | origin = 'http://blog.example.org' 272 | obj.origin_policy = {'http://blog.example.org'} 273 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 274 | 275 | origin = 'http://blog.example.org' 276 | obj.origin_policy = {'https://blog.example.org'} 277 | self.assertFalse(WsockHandler.check_origin(obj, origin)) 278 | 279 | obj.origin_policy = '*' 280 | origin = 'https://blog.example.org' 281 | self.assertTrue(WsockHandler.check_origin(obj, origin)) 282 | 283 | def test_failed_weak_ref(self): 284 | request = HTTPServerRequest(uri='/') 285 | obj = Mock(spec=WsockHandler, request=request) 286 | obj.src_addr = ("127.0.0.1", 8888) 287 | 288 | class FakeWeakRef: 289 | def __init__(self): 290 | self.count = 0 291 | 292 | def __call__(self): 293 | self.count += 1 294 | return None 295 | 296 | ref = FakeWeakRef() 297 | obj.worker_ref = ref 298 | WsockHandler.on_message(obj, b'{"data": "somestuff"}') 299 | self.assertGreaterEqual(ref.count, 1) 300 | obj.close.assert_called_with(reason='No worker found') 301 | 302 | def test_worker_closed(self): 303 | request = HTTPServerRequest(uri='/') 304 | obj = Mock(spec=WsockHandler, request=request) 305 | obj.src_addr = ("127.0.0.1", 8888) 306 | 307 | class Worker: 308 | def __init__(self): 309 | self.closed = True 310 | 311 | class FakeWeakRef: 312 | def __call__(self): 313 | return Worker() 314 | 315 | ref = FakeWeakRef() 316 | obj.worker_ref = ref 317 | WsockHandler.on_message(obj, b'{"data": "somestuff"}') 318 | obj.close.assert_called_with(reason='Worker closed') 319 | 320 | class TestIndexHandler(unittest.TestCase): 321 | def test_null_in_encoding(self): 322 | handler = Mock(spec=IndexHandler) 323 | 324 | # This is a little nasty, but the index handler has a lot of 325 | # dependencies to mock. Mocking out everything but the bits 326 | # we want to test lets us test this case without needing to 327 | # refactor the relevant code out of IndexHandler 328 | def parse_encoding(data): 329 | return IndexHandler.parse_encoding(handler, data) 330 | handler.parse_encoding = parse_encoding 331 | 332 | ssh = Mock(spec=SSHClient) 333 | stdin = io.BytesIO() 334 | stdout = io.BytesIO(initial_bytes=b"UTF-8\0") 335 | stderr = io.BytesIO() 336 | ssh.exec_command.return_value = (stdin, stdout, stderr) 337 | 338 | encoding = IndexHandler.get_default_encoding(handler, ssh) 339 | self.assertEquals("utf-8", encoding) 340 | 341 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tornado.web import Application 4 | from webssh import handler 5 | from webssh.main import app_listen 6 | 7 | 8 | class TestMain(unittest.TestCase): 9 | 10 | def test_app_listen(self): 11 | app = Application() 12 | app.listen = lambda x, y, **kwargs: 1 13 | 14 | handler.redirecting = None 15 | server_settings = dict() 16 | app_listen(app, 80, '127.0.0.1', server_settings) 17 | self.assertFalse(handler.redirecting) 18 | 19 | handler.redirecting = None 20 | server_settings = dict(ssl_options='enabled') 21 | app_listen(app, 80, '127.0.0.1', server_settings) 22 | self.assertTrue(handler.redirecting) 23 | -------------------------------------------------------------------------------- /tests/test_policy.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import paramiko 4 | 5 | from shutil import copyfile 6 | from paramiko.client import RejectPolicy, WarningPolicy 7 | from tests.utils import make_tests_data_path 8 | from webssh.policy import ( 9 | AutoAddPolicy, get_policy_dictionary, load_host_keys, 10 | get_policy_class, check_policy_setting 11 | ) 12 | 13 | 14 | class TestPolicy(unittest.TestCase): 15 | 16 | def test_get_policy_dictionary(self): 17 | classes = [AutoAddPolicy, RejectPolicy, WarningPolicy] 18 | dic = get_policy_dictionary() 19 | for cls in classes: 20 | val = dic[cls.__name__.lower()] 21 | self.assertIs(cls, val) 22 | 23 | def test_load_host_keys(self): 24 | path = '/path-not-exists' 25 | host_keys = load_host_keys(path) 26 | self.assertFalse(host_keys) 27 | 28 | path = '/tmp' 29 | host_keys = load_host_keys(path) 30 | self.assertFalse(host_keys) 31 | 32 | path = make_tests_data_path('known_hosts_example') 33 | host_keys = load_host_keys(path) 34 | self.assertEqual(host_keys, paramiko.hostkeys.HostKeys(path)) 35 | 36 | def test_get_policy_class(self): 37 | keys = ['autoadd', 'reject', 'warning'] 38 | vals = [AutoAddPolicy, RejectPolicy, WarningPolicy] 39 | for key, val in zip(keys, vals): 40 | cls = get_policy_class(key) 41 | self.assertIs(cls, val) 42 | 43 | key = 'non-exists' 44 | with self.assertRaises(ValueError): 45 | get_policy_class(key) 46 | 47 | def test_check_policy_setting(self): 48 | host_keys_filename = make_tests_data_path('host_keys_test.db') 49 | host_keys_settings = dict( 50 | host_keys=paramiko.hostkeys.HostKeys(), 51 | system_host_keys=paramiko.hostkeys.HostKeys(), 52 | host_keys_filename=host_keys_filename 53 | ) 54 | 55 | with self.assertRaises(ValueError): 56 | check_policy_setting(RejectPolicy, host_keys_settings) 57 | 58 | try: 59 | os.unlink(host_keys_filename) 60 | except OSError: 61 | pass 62 | check_policy_setting(AutoAddPolicy, host_keys_settings) 63 | self.assertEqual(os.path.exists(host_keys_filename), True) 64 | 65 | def test_is_missing_host_key(self): 66 | client = paramiko.SSHClient() 67 | file1 = make_tests_data_path('known_hosts_example') 68 | file2 = make_tests_data_path('known_hosts_example2') 69 | client.load_host_keys(file1) 70 | client.load_system_host_keys(file2) 71 | 72 | autoadd = AutoAddPolicy() 73 | for f in [file1, file2]: 74 | entry = paramiko.hostkeys.HostKeys(f)._entries[0] 75 | hostname = entry.hostnames[0] 76 | key = entry.key 77 | self.assertIsNone( 78 | autoadd.is_missing_host_key(client, hostname, key) 79 | ) 80 | 81 | for f in [file1, file2]: 82 | entry = paramiko.hostkeys.HostKeys(f)._entries[0] 83 | hostname = entry.hostnames[0] 84 | key = entry.key 85 | key.get_name = lambda: 'unknown' 86 | self.assertTrue( 87 | autoadd.is_missing_host_key(client, hostname, key) 88 | ) 89 | del key.get_name 90 | 91 | for f in [file1, file2]: 92 | entry = paramiko.hostkeys.HostKeys(f)._entries[0] 93 | hostname = entry.hostnames[0][1:] 94 | key = entry.key 95 | self.assertTrue( 96 | autoadd.is_missing_host_key(client, hostname, key) 97 | ) 98 | 99 | file3 = make_tests_data_path('known_hosts_example3') 100 | entry = paramiko.hostkeys.HostKeys(file3)._entries[0] 101 | hostname = entry.hostnames[0] 102 | key = entry.key 103 | with self.assertRaises(paramiko.BadHostKeyException): 104 | autoadd.is_missing_host_key(client, hostname, key) 105 | 106 | def test_missing_host_key(self): 107 | client = paramiko.SSHClient() 108 | file1 = make_tests_data_path('known_hosts_example') 109 | file2 = make_tests_data_path('known_hosts_example2') 110 | filename = make_tests_data_path('known_hosts') 111 | copyfile(file1, filename) 112 | client.load_host_keys(filename) 113 | n1 = len(client._host_keys) 114 | 115 | autoadd = AutoAddPolicy() 116 | entry = paramiko.hostkeys.HostKeys(file2)._entries[0] 117 | hostname = entry.hostnames[0] 118 | key = entry.key 119 | autoadd.missing_host_key(client, hostname, key) 120 | self.assertEqual(len(client._host_keys), n1 + 1) 121 | self.assertEqual(paramiko.hostkeys.HostKeys(filename), 122 | client._host_keys) 123 | os.unlink(filename) 124 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import io 2 | import random 3 | import ssl 4 | import sys 5 | import os.path 6 | import unittest 7 | import paramiko 8 | import tornado.options as options 9 | 10 | from tests.utils import make_tests_data_path 11 | from webssh.policy import load_host_keys 12 | from webssh.settings import ( 13 | get_host_keys_settings, get_policy_setting, base_dir, get_font_filename, 14 | get_ssl_context, get_trusted_downstream, get_origin_setting, print_version, 15 | check_encoding_setting 16 | ) 17 | from webssh.utils import UnicodeType 18 | from webssh._version import __version__ 19 | 20 | 21 | class TestSettings(unittest.TestCase): 22 | 23 | def test_print_version(self): 24 | sys_stdout = sys.stdout 25 | sys.stdout = io.StringIO() if UnicodeType == str else io.BytesIO() 26 | 27 | self.assertEqual(print_version(False), None) 28 | self.assertEqual(sys.stdout.getvalue(), '') 29 | 30 | with self.assertRaises(SystemExit): 31 | self.assertEqual(print_version(True), None) 32 | self.assertEqual(sys.stdout.getvalue(), __version__ + '\n') 33 | 34 | sys.stdout = sys_stdout 35 | 36 | def test_get_host_keys_settings(self): 37 | options.hostfile = '' 38 | options.syshostfile = '' 39 | dic = get_host_keys_settings(options) 40 | 41 | filename = os.path.join(base_dir, 'known_hosts') 42 | self.assertEqual(dic['host_keys'], load_host_keys(filename)) 43 | self.assertEqual(dic['host_keys_filename'], filename) 44 | self.assertEqual( 45 | dic['system_host_keys'], 46 | load_host_keys(os.path.expanduser('~/.ssh/known_hosts')) 47 | ) 48 | 49 | options.hostfile = make_tests_data_path('known_hosts_example') 50 | options.syshostfile = make_tests_data_path('known_hosts_example2') 51 | dic2 = get_host_keys_settings(options) 52 | self.assertEqual(dic2['host_keys'], load_host_keys(options.hostfile)) 53 | self.assertEqual(dic2['host_keys_filename'], options.hostfile) 54 | self.assertEqual(dic2['system_host_keys'], 55 | load_host_keys(options.syshostfile)) 56 | 57 | def test_get_policy_setting(self): 58 | options.policy = 'warning' 59 | options.hostfile = '' 60 | options.syshostfile = '' 61 | settings = get_host_keys_settings(options) 62 | instance = get_policy_setting(options, settings) 63 | self.assertIsInstance(instance, paramiko.client.WarningPolicy) 64 | 65 | options.policy = 'autoadd' 66 | options.hostfile = '' 67 | options.syshostfile = '' 68 | settings = get_host_keys_settings(options) 69 | instance = get_policy_setting(options, settings) 70 | self.assertIsInstance(instance, paramiko.client.AutoAddPolicy) 71 | os.unlink(settings['host_keys_filename']) 72 | 73 | options.policy = 'reject' 74 | options.hostfile = '' 75 | options.syshostfile = '' 76 | settings = get_host_keys_settings(options) 77 | try: 78 | instance = get_policy_setting(options, settings) 79 | except ValueError: 80 | self.assertFalse( 81 | settings['host_keys'] and settings['system_host_keys'] 82 | ) 83 | else: 84 | self.assertIsInstance(instance, paramiko.client.RejectPolicy) 85 | 86 | def test_get_ssl_context(self): 87 | options.certfile = '' 88 | options.keyfile = '' 89 | ssl_ctx = get_ssl_context(options) 90 | self.assertIsNone(ssl_ctx) 91 | 92 | options.certfile = 'provided' 93 | options.keyfile = '' 94 | with self.assertRaises(ValueError) as ctx: 95 | ssl_ctx = get_ssl_context(options) 96 | self.assertEqual('keyfile is not provided', str(ctx.exception)) 97 | 98 | options.certfile = '' 99 | options.keyfile = 'provided' 100 | with self.assertRaises(ValueError) as ctx: 101 | ssl_ctx = get_ssl_context(options) 102 | self.assertEqual('certfile is not provided', str(ctx.exception)) 103 | 104 | options.certfile = 'FileDoesNotExist' 105 | options.keyfile = make_tests_data_path('cert.key') 106 | with self.assertRaises(ValueError) as ctx: 107 | ssl_ctx = get_ssl_context(options) 108 | self.assertIn('does not exist', str(ctx.exception)) 109 | 110 | options.certfile = make_tests_data_path('cert.key') 111 | options.keyfile = 'FileDoesNotExist' 112 | with self.assertRaises(ValueError) as ctx: 113 | ssl_ctx = get_ssl_context(options) 114 | self.assertIn('does not exist', str(ctx.exception)) 115 | 116 | options.certfile = make_tests_data_path('cert.key') 117 | options.keyfile = make_tests_data_path('cert.key') 118 | with self.assertRaises(ssl.SSLError) as ctx: 119 | ssl_ctx = get_ssl_context(options) 120 | 121 | options.certfile = make_tests_data_path('cert.crt') 122 | options.keyfile = make_tests_data_path('cert.key') 123 | ssl_ctx = get_ssl_context(options) 124 | self.assertIsNotNone(ssl_ctx) 125 | 126 | def test_get_trusted_downstream(self): 127 | tdstream = '' 128 | result = set() 129 | self.assertEqual(get_trusted_downstream(tdstream), result) 130 | 131 | tdstream = '1.1.1.1, 2.2.2.2' 132 | result = set(['1.1.1.1', '2.2.2.2']) 133 | self.assertEqual(get_trusted_downstream(tdstream), result) 134 | 135 | tdstream = '1.1.1.1, 2.2.2.2, 2.2.2.2' 136 | result = set(['1.1.1.1', '2.2.2.2']) 137 | self.assertEqual(get_trusted_downstream(tdstream), result) 138 | 139 | tdstream = '1.1.1.1, 2.2.2.' 140 | with self.assertRaises(ValueError): 141 | get_trusted_downstream(tdstream) 142 | 143 | def test_get_origin_setting(self): 144 | options.debug = False 145 | options.origin = '*' 146 | with self.assertRaises(ValueError): 147 | get_origin_setting(options) 148 | 149 | options.debug = True 150 | self.assertEqual(get_origin_setting(options), '*') 151 | 152 | options.origin = random.choice(['Same', 'Primary']) 153 | self.assertEqual(get_origin_setting(options), options.origin.lower()) 154 | 155 | options.origin = '' 156 | with self.assertRaises(ValueError): 157 | get_origin_setting(options) 158 | 159 | options.origin = ',' 160 | with self.assertRaises(ValueError): 161 | get_origin_setting(options) 162 | 163 | options.origin = 'www.example.com, https://www.example.org' 164 | result = {'http://www.example.com', 'https://www.example.org'} 165 | self.assertEqual(get_origin_setting(options), result) 166 | 167 | options.origin = 'www.example.com:80, www.example.org:443' 168 | result = {'http://www.example.com', 'https://www.example.org'} 169 | self.assertEqual(get_origin_setting(options), result) 170 | 171 | def test_get_font_setting(self): 172 | font_dir = os.path.join(base_dir, 'tests', 'data', 'fonts') 173 | font = '' 174 | self.assertEqual(get_font_filename(font, font_dir), 'fake-font') 175 | 176 | font = 'fake-font' 177 | self.assertEqual(get_font_filename(font, font_dir), 'fake-font') 178 | 179 | font = 'wrong-name' 180 | with self.assertRaises(ValueError): 181 | get_font_filename(font, font_dir) 182 | 183 | def test_check_encoding_setting(self): 184 | self.assertIsNone(check_encoding_setting('')) 185 | self.assertIsNone(check_encoding_setting('utf-8')) 186 | with self.assertRaises(ValueError): 187 | check_encoding_setting('unknown-encoding') 188 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from webssh.utils import ( 4 | is_valid_ip_address, is_valid_port, is_valid_hostname, to_str, to_bytes, 5 | to_int, is_ip_hostname, is_same_primary_domain, parse_origin_from_url 6 | ) 7 | 8 | 9 | class TestUitls(unittest.TestCase): 10 | 11 | def test_to_str(self): 12 | b = b'hello' 13 | u = u'hello' 14 | self.assertEqual(to_str(b), u) 15 | self.assertEqual(to_str(u), u) 16 | 17 | def test_to_bytes(self): 18 | b = b'hello' 19 | u = u'hello' 20 | self.assertEqual(to_bytes(b), b) 21 | self.assertEqual(to_bytes(u), b) 22 | 23 | def test_to_int(self): 24 | self.assertEqual(to_int(''), None) 25 | self.assertEqual(to_int(None), None) 26 | self.assertEqual(to_int('22'), 22) 27 | self.assertEqual(to_int(' 22 '), 22) 28 | 29 | def test_is_valid_ip_address(self): 30 | self.assertFalse(is_valid_ip_address('127.0.0')) 31 | self.assertFalse(is_valid_ip_address(b'127.0.0')) 32 | self.assertTrue(is_valid_ip_address('127.0.0.1')) 33 | self.assertTrue(is_valid_ip_address(b'127.0.0.1')) 34 | self.assertFalse(is_valid_ip_address('abc')) 35 | self.assertFalse(is_valid_ip_address(b'abc')) 36 | self.assertTrue(is_valid_ip_address('::1')) 37 | self.assertTrue(is_valid_ip_address(b'::1')) 38 | self.assertTrue(is_valid_ip_address('fe80::1111:2222:3333:4444')) 39 | self.assertTrue(is_valid_ip_address(b'fe80::1111:2222:3333:4444')) 40 | self.assertTrue(is_valid_ip_address('fe80::1111:2222:3333:4444%eth0')) 41 | self.assertTrue(is_valid_ip_address(b'fe80::1111:2222:3333:4444%eth0')) 42 | 43 | def test_is_valid_port(self): 44 | self.assertTrue(is_valid_port(80)) 45 | self.assertFalse(is_valid_port(0)) 46 | self.assertFalse(is_valid_port(65536)) 47 | 48 | def test_is_valid_hostname(self): 49 | self.assertTrue(is_valid_hostname('google.com')) 50 | self.assertTrue(is_valid_hostname('google.com.')) 51 | self.assertTrue(is_valid_hostname('www.google.com')) 52 | self.assertTrue(is_valid_hostname('www.google.com.')) 53 | self.assertFalse(is_valid_hostname('.www.google.com')) 54 | self.assertFalse(is_valid_hostname('http://www.google.com')) 55 | self.assertFalse(is_valid_hostname('https://www.google.com')) 56 | self.assertFalse(is_valid_hostname('127.0.0.1')) 57 | self.assertFalse(is_valid_hostname('::1')) 58 | 59 | def test_is_ip_hostname(self): 60 | self.assertTrue(is_ip_hostname('[::1]')) 61 | self.assertTrue(is_ip_hostname('127.0.0.1')) 62 | self.assertFalse(is_ip_hostname('localhost')) 63 | self.assertFalse(is_ip_hostname('www.google.com')) 64 | 65 | def test_is_same_primary_domain(self): 66 | domain1 = 'localhost' 67 | domain2 = 'localhost' 68 | self.assertTrue(is_same_primary_domain(domain1, domain2)) 69 | 70 | domain1 = 'localhost' 71 | domain2 = 'test' 72 | self.assertFalse(is_same_primary_domain(domain1, domain2)) 73 | 74 | domain1 = 'com' 75 | domain2 = 'example.com' 76 | self.assertFalse(is_same_primary_domain(domain1, domain2)) 77 | 78 | domain1 = 'example.com' 79 | domain2 = 'example.com' 80 | self.assertTrue(is_same_primary_domain(domain1, domain2)) 81 | 82 | domain1 = 'www.example.com' 83 | domain2 = 'example.com' 84 | self.assertTrue(is_same_primary_domain(domain1, domain2)) 85 | 86 | domain1 = 'wwwexample.com' 87 | domain2 = 'example.com' 88 | self.assertFalse(is_same_primary_domain(domain1, domain2)) 89 | 90 | domain1 = 'www.example.com' 91 | domain2 = 'www2.example.com' 92 | self.assertTrue(is_same_primary_domain(domain1, domain2)) 93 | 94 | domain1 = 'xxx.www.example.com' 95 | domain2 = 'xxx.www2.example.com' 96 | self.assertTrue(is_same_primary_domain(domain1, domain2)) 97 | 98 | def test_parse_origin_from_url(self): 99 | url = '' 100 | self.assertIsNone(parse_origin_from_url(url)) 101 | 102 | url = 'www.example.com' 103 | self.assertEqual(parse_origin_from_url(url), 'http://www.example.com') 104 | 105 | url = 'http://www.example.com' 106 | self.assertEqual(parse_origin_from_url(url), 'http://www.example.com') 107 | 108 | url = 'www.example.com:80' 109 | self.assertEqual(parse_origin_from_url(url), 'http://www.example.com') 110 | 111 | url = 'http://www.example.com:80' 112 | self.assertEqual(parse_origin_from_url(url), 'http://www.example.com') 113 | 114 | url = 'www.example.com:443' 115 | self.assertEqual(parse_origin_from_url(url), 'https://www.example.com') 116 | 117 | url = 'https://www.example.com' 118 | self.assertEqual(parse_origin_from_url(url), 'https://www.example.com') 119 | 120 | url = 'https://www.example.com:443' 121 | self.assertEqual(parse_origin_from_url(url), 'https://www.example.com') 122 | 123 | url = 'https://www.example.com:80' 124 | self.assertEqual(parse_origin_from_url(url), url) 125 | 126 | url = 'http://www.example.com:443' 127 | self.assertEqual(parse_origin_from_url(url), url) 128 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os.path 3 | from uuid import uuid4 4 | from webssh.settings import base_dir 5 | 6 | 7 | def encode_multipart_formdata(fields, files): 8 | """ 9 | fields is a sequence of (name, value) elements for regular form fields. 10 | files is a sequence of (name, filename, value) elements for data to be 11 | uploaded as files. 12 | Return (content_type, body) ready for httplib.HTTP instance 13 | """ 14 | boundary = uuid4().hex 15 | CRLF = '\r\n' 16 | L = [] 17 | for (key, value) in fields: 18 | L.append('--' + boundary) 19 | L.append('Content-Disposition: form-data; name="%s"' % key) 20 | L.append('') 21 | L.append(value) 22 | for (key, filename, value) in files: 23 | L.append('--' + boundary) 24 | L.append( 25 | 'Content-Disposition: form-data; name="%s"; filename="%s"' % ( 26 | key, filename 27 | ) 28 | ) 29 | L.append('Content-Type: %s' % get_content_type(filename)) 30 | L.append('') 31 | L.append(value) 32 | L.append('--' + boundary + '--') 33 | L.append('') 34 | body = CRLF.join(L) 35 | content_type = 'multipart/form-data; boundary=%s' % boundary 36 | return content_type, body 37 | 38 | 39 | def get_content_type(filename): 40 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 41 | 42 | 43 | def read_file(path, encoding='utf-8'): 44 | with open(path, 'rb') as f: 45 | data = f.read() 46 | if encoding is None: 47 | return data 48 | return data.decode(encoding) 49 | 50 | 51 | def make_tests_data_path(filename): 52 | return os.path.join(base_dir, 'tests', 'data', filename) 53 | -------------------------------------------------------------------------------- /user.js/Build-SSH-Link.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Build SSH Link 3 | // @namespace http://tampermonkey.net/ 4 | // @version 0.1 5 | // @description Build SSH link for huashengdun-webssh 6 | // @author ǝɔ∀ǝdʎz∀ɹɔ 👽 7 | // @match https://ssh.vps.vc/* 8 | // @match https://ssh.hax.co.id/* 9 | // @match https://ssh-crazypeace.koyeb.app/* 10 | // @icon https://www.google.com/s2/favicons?sz=64&domain=koyeb.app 11 | // @grant none 12 | // ==/UserScript== 13 | 14 | 15 | (function() { 16 | 'use strict'; 17 | 18 | // Your code here... 19 | // 获取 form 元素 20 | var form = document.getElementById("connect"); 21 | 22 | ///////////////////// 23 | // 创建 ` 544 | 547 | 550 | 551 | 552 | 553 | 557 | 558 | 559 | 560 |
561 |
562 |
563 |
564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | -------------------------------------------------------------------------------- /webssh/utils.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import re 3 | 4 | try: 5 | from types import UnicodeType 6 | except ImportError: 7 | UnicodeType = str 8 | 9 | try: 10 | from urllib.parse import urlparse 11 | except ImportError: 12 | from urlparse import urlparse 13 | 14 | 15 | numeric = re.compile(r'[0-9]+$') 16 | allowed = re.compile(r'(?!-)[a-z0-9-]{1,63}(? 253: 82 | return False 83 | 84 | labels = hostname.split('.') 85 | 86 | # the TLD must be not all-numeric 87 | if numeric.match(labels[-1]): 88 | return False 89 | 90 | return all(allowed.match(label) for label in labels) 91 | 92 | 93 | def is_same_primary_domain(domain1, domain2): 94 | i = -1 95 | dots = 0 96 | l1 = len(domain1) 97 | l2 = len(domain2) 98 | m = min(l1, l2) 99 | 100 | while i >= -m: 101 | c1 = domain1[i] 102 | c2 = domain2[i] 103 | 104 | if c1 == c2: 105 | if c1 == '.': 106 | dots += 1 107 | if dots == 2: 108 | return True 109 | else: 110 | return False 111 | 112 | i -= 1 113 | 114 | if l1 == l2: 115 | return True 116 | 117 | if dots == 0: 118 | return False 119 | 120 | c = domain1[i] if l1 > m else domain2[i] 121 | return c == '.' 122 | 123 | 124 | def parse_origin_from_url(url): 125 | url = url.strip() 126 | if not url: 127 | return 128 | 129 | if not (url.startswith('http://') or url.startswith('https://') or 130 | url.startswith('//')): 131 | url = '//' + url 132 | 133 | parsed = urlparse(url) 134 | port = parsed.port 135 | scheme = parsed.scheme 136 | 137 | if scheme == '': 138 | scheme = 'https' if port == 443 else 'http' 139 | 140 | if port == 443 and scheme == 'https': 141 | netloc = parsed.netloc.replace(':443', '') 142 | elif port == 80 and scheme == 'http': 143 | netloc = parsed.netloc.replace(':80', '') 144 | else: 145 | netloc = parsed.netloc 146 | 147 | return '{}://{}'.format(scheme, netloc) 148 | -------------------------------------------------------------------------------- /webssh/worker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | try: 3 | import secrets 4 | except ImportError: 5 | secrets = None 6 | import tornado.websocket 7 | 8 | from uuid import uuid4 9 | from tornado.ioloop import IOLoop 10 | from tornado.iostream import _ERRNO_CONNRESET 11 | from tornado.util import errno_from_exception 12 | 13 | 14 | BUF_SIZE = 32 * 1024 15 | clients = {} # {ip: {id: worker}} 16 | 17 | 18 | def clear_worker(worker, clients): 19 | ip = worker.src_addr[0] 20 | workers = clients.get(ip) 21 | assert worker.id in workers 22 | workers.pop(worker.id) 23 | 24 | if not workers: 25 | clients.pop(ip) 26 | if not clients: 27 | clients.clear() 28 | 29 | 30 | def recycle_worker(worker): 31 | if worker.handler: 32 | return 33 | logging.warning('Recycling worker {}'.format(worker.id)) 34 | worker.close(reason='worker recycled') 35 | 36 | 37 | class Worker(object): 38 | def __init__(self, loop, ssh, chan, dst_addr): 39 | self.loop = loop 40 | self.ssh = ssh 41 | self.chan = chan 42 | self.dst_addr = dst_addr 43 | self.fd = chan.fileno() 44 | self.id = self.gen_id() 45 | self.data_to_dst = [] 46 | self.handler = None 47 | self.mode = IOLoop.READ 48 | self.closed = False 49 | 50 | def __call__(self, fd, events): 51 | if events & IOLoop.READ: 52 | self.on_read() 53 | if events & IOLoop.WRITE: 54 | self.on_write() 55 | if events & IOLoop.ERROR: 56 | self.close(reason='error event occurred') 57 | 58 | @classmethod 59 | def gen_id(cls): 60 | return secrets.token_urlsafe(nbytes=32) if secrets else uuid4().hex 61 | 62 | def set_handler(self, handler): 63 | if not self.handler: 64 | self.handler = handler 65 | 66 | def update_handler(self, mode): 67 | if self.mode != mode: 68 | self.loop.update_handler(self.fd, mode) 69 | self.mode = mode 70 | if mode == IOLoop.WRITE: 71 | self.loop.call_later(0.1, self, self.fd, IOLoop.WRITE) 72 | 73 | def on_read(self): 74 | logging.debug('worker {} on read'.format(self.id)) 75 | try: 76 | data = self.chan.recv(BUF_SIZE) 77 | except (OSError, IOError) as e: 78 | logging.error(e) 79 | if self.chan.closed or errno_from_exception(e) in _ERRNO_CONNRESET: 80 | self.close(reason='chan error on reading') 81 | else: 82 | logging.debug('{!r} from {}:{}'.format(data, *self.dst_addr)) 83 | if not data: 84 | self.close(reason='chan closed') 85 | return 86 | 87 | logging.debug('{!r} to {}:{}'.format(data, *self.handler.src_addr)) 88 | try: 89 | self.handler.write_message(data, binary=True) 90 | except tornado.websocket.WebSocketClosedError: 91 | self.close(reason='websocket closed') 92 | 93 | def on_write(self): 94 | logging.debug('worker {} on write'.format(self.id)) 95 | if not self.data_to_dst: 96 | return 97 | 98 | data = ''.join(self.data_to_dst) 99 | logging.debug('{!r} to {}:{}'.format(data, *self.dst_addr)) 100 | 101 | try: 102 | sent = self.chan.send(data) 103 | except (OSError, IOError) as e: 104 | logging.error(e) 105 | if self.chan.closed or errno_from_exception(e) in _ERRNO_CONNRESET: 106 | self.close(reason='chan error on writing') 107 | else: 108 | self.update_handler(IOLoop.WRITE) 109 | else: 110 | self.data_to_dst = [] 111 | data = data[sent:] 112 | if data: 113 | self.data_to_dst.append(data) 114 | self.update_handler(IOLoop.WRITE) 115 | else: 116 | self.update_handler(IOLoop.READ) 117 | 118 | def close(self, reason=None): 119 | if self.closed: 120 | return 121 | self.closed = True 122 | 123 | logging.info( 124 | 'Closing worker {} with reason: {}'.format(self.id, reason) 125 | ) 126 | if self.handler: 127 | self.loop.remove_handler(self.fd) 128 | self.handler.close(reason=reason) 129 | self.chan.close() 130 | self.ssh.close() 131 | logging.info('Connection to {}:{} lost'.format(*self.dst_addr)) 132 | 133 | clear_worker(self, clients) 134 | logging.debug(clients) 135 | --------------------------------------------------------------------------------