├── tests ├── __init__.py ├── data │ ├── fonts │ │ ├── .gitignore │ │ └── fake-font │ ├── known_hosts_example │ ├── known_hosts_example2 │ ├── known_hosts_example3 │ ├── test_known_hosts │ ├── test_ed25519.key │ ├── test_ed25519_password.key │ ├── test_rsa.key │ ├── user_rsa_key │ ├── test_rsa_password.key │ ├── cert.crt │ ├── test_new_dsa.key │ ├── cert.key │ └── test_new_rsa_password.key ├── test_main.py ├── utils.py ├── test_policy.py ├── test_utils.py ├── test_settings.py ├── sshserver.py ├── test_handler.py └── test_app.py ├── .dockerignore ├── webssh ├── static │ ├── css │ │ ├── fonts │ │ │ ├── .gitignore │ │ │ └── Consolas.ttf │ │ ├── fullscreen.min.css │ │ └── xterm.min.css │ ├── img │ │ └── favicon.png │ └── js │ │ ├── xterm-addon-fit.min.js │ │ ├── popper.min.js │ │ └── main.js ├── _version.py ├── __init__.py ├── main.py ├── policy.py ├── utils.py ├── templates │ └── index.html ├── worker.py ├── settings.py └── handler.py ├── requirements.txt ├── run.py ├── preview ├── login.png └── terminal.png ├── docker-compose.yml ├── .github ├── FUNDING.yml └── workflows │ └── python.yml ├── setup.cfg ├── .coveragerc ├── MANIFEST.in ├── Dockerfile ├── .gitignore ├── setup.py ├── LICENSE ├── user.js └── Build-SSH-Link.user.js ├── README.rst └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | -------------------------------------------------------------------------------- /tests/data/fonts/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/fonts/fake-font: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webssh/static/css/fonts/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paramiko==3.0.0 2 | tornado==6.2.0 3 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from webssh.main import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /preview/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazypeace/huashengdun-webssh/HEAD/preview/login.png -------------------------------------------------------------------------------- /preview/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazypeace/huashengdun-webssh/HEAD/preview/terminal.png -------------------------------------------------------------------------------- /webssh/_version.py: -------------------------------------------------------------------------------- 1 | __version_info__ = (1, 6, 2) 2 | __version__ = '.'.join(map(str, __version_info__)) 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "8888:8888" 7 | -------------------------------------------------------------------------------- /webssh/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazypeace/huashengdun-webssh/HEAD/webssh/static/img/favicon.png -------------------------------------------------------------------------------- /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_known_hosts: -------------------------------------------------------------------------------- 1 | [127.0.0.1]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINwZGQmNFADnAAlm5uFLQTrdxqpNxHdgg4JPbB3sR2kr 2 | -------------------------------------------------------------------------------- /webssh/static/css/fonts/Consolas.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazypeace/huashengdun-webssh/HEAD/webssh/static/css/fonts/Consolas.ttf -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webssh/static/css/fullscreen.min.css: -------------------------------------------------------------------------------- 1 | .xterm.fullscreen{position:fixed;top:0;bottom:0;left:0;right:0;width:auto;height:auto;z-index:255} 2 | /*# sourceMappingURL=fullscreen.min.css.map */ -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webssh/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from webssh._version import __version__, __version_info__ 3 | 4 | 5 | __author__ = 'Shengdun Hua ' 6 | 7 | if sys.platform == 'win32' and sys.version_info.major == 3 and \ 8 | sys.version_info.minor >= 8: 9 | import asyncio 10 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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"] 19 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | jobs: 9 | ruff: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - run: pip install --user ruff 14 | - run: ruff --format=github --ignore=F401 --target-version=py38 . 15 | pytest: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11"] 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - run: pip install pytest pytest-cov -r requirements.txt 27 | - run: pytest --cov=webssh 28 | - run: mkdir -p coverage 29 | - uses: tj-actions/coverage-badge-py@v2 30 | with: 31 | output: coverage/coverage.svg 32 | - uses: JamesIves/github-pages-deploy-action@v4 33 | with: 34 | branch: coverage-badge 35 | folder: coverage 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webssh/static/css/xterm.min.css: -------------------------------------------------------------------------------- 1 | .xterm{font-feature-settings:"liga" 0;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm{cursor:text}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:0.5}.xterm-underline{text-decoration:underline} -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webssh/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import tornado.web 3 | import tornado.ioloop 4 | 5 | from tornado.options import options 6 | from webssh import handler 7 | from webssh.handler import IndexHandler, WsockHandler, NotFoundHandler 8 | from webssh.settings import ( 9 | get_app_settings, get_host_keys_settings, get_policy_setting, 10 | get_ssl_context, get_server_settings, check_encoding_setting 11 | ) 12 | 13 | 14 | def make_handlers(loop, options): 15 | host_keys_settings = get_host_keys_settings(options) 16 | policy = get_policy_setting(options, host_keys_settings) 17 | 18 | handlers = [ 19 | (r'/', IndexHandler, dict(loop=loop, policy=policy, 20 | host_keys_settings=host_keys_settings)), 21 | (r'/ws', WsockHandler, dict(loop=loop)) 22 | ] 23 | return handlers 24 | 25 | 26 | def make_app(handlers, settings): 27 | settings.update(default_handler_class=NotFoundHandler) 28 | return tornado.web.Application(handlers, **settings) 29 | 30 | 31 | def app_listen(app, port, address, server_settings): 32 | app.listen(port, address, **server_settings) 33 | if not server_settings.get('ssl_options'): 34 | server_type = 'http' 35 | else: 36 | server_type = 'https' 37 | handler.redirecting = True if options.redirect else False 38 | logging.info( 39 | 'Listening on {}:{} ({})'.format(address, port, server_type) 40 | ) 41 | 42 | 43 | def main(): 44 | options.parse_command_line() 45 | check_encoding_setting(options.encoding) 46 | loop = tornado.ioloop.IOLoop.current() 47 | app = make_app(make_handlers(loop, options), get_app_settings(options)) 48 | ssl_ctx = get_ssl_context(options) 49 | server_settings = get_server_settings(options) 50 | app_listen(app, options.port, options.address, server_settings) 51 | if ssl_ctx: 52 | server_settings.update(ssl_options=ssl_ctx) 53 | app_listen(app, options.sslport, options.ssladdress, server_settings) 54 | loop.start() 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /webssh/static/js/xterm-addon-fit.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(window,function(){return function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core,t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),n=Math.max(0,parseInt(t.getPropertyValue("width"))),o=window.getComputedStyle(this._terminal.element),i=r-(parseInt(o.getPropertyValue("padding-top"))+parseInt(o.getPropertyValue("padding-bottom"))),a=n-(parseInt(o.getPropertyValue("padding-right"))+parseInt(o.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(i/e._renderService.dimensions.actualCellHeight))}}},e}();t.FitAddon=n}])}); 2 | //# sourceMappingURL=xterm-addon-fit.js.map -------------------------------------------------------------------------------- /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 | // 创建 ` 87 | 88 | 89 | 90 |
Github: https://github.com/crazypeace/huashengdun-webssh
91 |
自建教程: https://zelikk.blogspot.com/search/label/webssh
92 | 93 | 94 | 95 |
96 |
97 |
98 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 我的修改 2 | 增加生成SSH Link功能,方便收藏,下次使用不需要输入密码。 3 | ![image](https://github.com/crazypeace/huashengdun-webssh/assets/665889/123a33bd-9514-46a5-8e64-d7a82b7f6f19) 4 | 5 | SSH Link 可以带一个命令参数. 登录完成后就执行命令. 6 | [![](https://res.cloudinary.com/marcomontalbano/image/upload/v1726912439/video_to_markdown/images/youtube--hCoAy06NA4k-c05b58ac6eb4c4700831b2b3070cd403.jpg)](https://www.youtube.com/watch?v=hCoAy06NA4k "") 7 | 8 | 部署到容器的教程: 9 | https://zelikk.blogspot.com/2023/10/huashengdun-webssh-codesandbox.html 10 | 11 | 部署到Hugging Face的教程 / 作者 Xiang xjfkkk 12 | https://linux.do/t/topic/135264 13 | 14 | 部署到 Serv00 教程 / 作者 Xiang xjfkkk 15 | https://linux.do/t/topic/211113 16 | 17 | 18 |
19 | 原项目readme (点击展开) 20 | 21 | ## WebSSH 22 | 23 | [![python](https://github.com/huashengdun/webssh/actions/workflows/python.yml/badge.svg)](https://github.com/huashengdun/webssh/actions/workflows/python.yml) 24 | [![codecov](https://raw.githubusercontent.com/huashengdun/webssh/coverage-badge/coverage.svg)](https://raw.githubusercontent.com/huashengdun/webssh/coverage-badge/coverage.svg) 25 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/webssh.svg) 26 | ![PyPI](https://img.shields.io/pypi/v/webssh.svg) 27 | 28 | 29 | ### Introduction 30 | 31 | A simple web application to be used as an ssh client to connect to your ssh servers. It is written in Python, base on tornado, paramiko and xterm.js. 32 | 33 | ### Features 34 | 35 | * SSH password authentication supported, including empty password. 36 | * SSH public-key authentication supported, including DSA RSA ECDSA Ed25519 keys. 37 | * Encrypted keys supported. 38 | * Two-Factor Authentication (time-based one-time password) supported. 39 | * Fullscreen terminal supported. 40 | * Terminal window resizable. 41 | * Auto detect the ssh server's default encoding. 42 | * Modern browsers including Chrome, Firefox, Safari, Edge, Opera supported. 43 | 44 | 45 | ### Preview 46 | 47 | ![Login](preview/login.png) 48 | ![Terminal](preview/terminal.png) 49 | 50 | 51 | ### How it works 52 | ``` 53 | +---------+ http +--------+ ssh +-----------+ 54 | | browser | <==========> | webssh | <=======> | ssh server| 55 | +---------+ websocket +--------+ ssh +-----------+ 56 | ``` 57 | 58 | ### Requirements 59 | 60 | * Python 3.8+ 61 | 62 | 63 | ### Quickstart 64 | 65 | 1. Install this app, run command `pip install webssh` 66 | 2. Start a webserver, run command `wssh` 67 | 3. Open your browser, navigate to `127.0.0.1:8888` 68 | 4. Input your data, submit the form. 69 | 70 | 71 | ### Server options 72 | 73 | ```bash 74 | # start a http server with specified listen address and listen port 75 | wssh --address='2.2.2.2' --port=8000 76 | 77 | # start a https server, certfile and keyfile must be passed 78 | wssh --certfile='/path/to/cert.crt' --keyfile='/path/to/cert.key' 79 | 80 | # missing host key policy 81 | wssh --policy=reject 82 | 83 | # logging level 84 | wssh --logging=debug 85 | 86 | # log to file 87 | wssh --log-file-prefix=main.log 88 | 89 | # more options 90 | wssh --help 91 | ``` 92 | 93 | ### Browser console 94 | 95 | ```javascript 96 | // connect to your ssh server 97 | wssh.connect(hostname, port, username, password, privatekey, passphrase, totp); 98 | 99 | // pass an object to wssh.connect 100 | var opts = { 101 | hostname: 'hostname', 102 | port: 'port', 103 | username: 'username', 104 | password: 'password', 105 | privatekey: 'the private key text', 106 | passphrase: 'passphrase', 107 | totp: 'totp' 108 | }; 109 | wssh.connect(opts); 110 | 111 | // without an argument, wssh will use the form data to connect 112 | wssh.connect(); 113 | 114 | // set a new encoding for client to use 115 | wssh.set_encoding(encoding); 116 | 117 | // reset encoding to use the default one 118 | wssh.reset_encoding(); 119 | 120 | // send a command to the server 121 | wssh.send('ls -l'); 122 | ``` 123 | 124 | ### Custom Font 125 | 126 | To use custom font, put your font file in the directory `webssh/static/css/fonts/` and restart the server. 127 | 128 | ### URL Arguments 129 | 130 | Support passing arguments by url (query or fragment) like following examples: 131 | 132 | Passing form data (password must be encoded in base64, privatekey not supported) 133 | ```bash 134 | http://localhost:8888/?hostname=xx&username=yy&password=str_base64_encoded 135 | ``` 136 | 137 | Passing a terminal background color 138 | ```bash 139 | http://localhost:8888/#bgcolor=green 140 | ``` 141 | 142 | Passing a terminal font color 143 | ```bash 144 | http://localhost:8888/#fontcolor=red 145 | ``` 146 | 147 | Passing a user defined title 148 | ```bash 149 | http://localhost:8888/?title=my-ssh-server 150 | ``` 151 | 152 | Passing an encoding 153 | ```bash 154 | http://localhost:8888/#encoding=gbk 155 | ``` 156 | 157 | Passing a font size 158 | ```bash 159 | http://localhost:8888/#fontsize=24 160 | ``` 161 | 162 | Passing a command executed right after login 163 | ```bash 164 | http://localhost:8888/?command=pwd 165 | ``` 166 | 167 | Passing a terminal type 168 | ```bash 169 | http://localhost:8888/?term=xterm-256color 170 | ``` 171 | 172 | ### Use Docker 173 | 174 | Start up the app 175 | ``` 176 | docker-compose up 177 | ``` 178 | 179 | Tear down the app 180 | ``` 181 | docker-compose down 182 | ``` 183 | 184 | ### Tests 185 | 186 | Requirements 187 | ``` 188 | pip install pytest pytest-cov codecov flake8 mock 189 | ``` 190 | 191 | Use unittest to run all tests 192 | ``` 193 | python -m unittest discover tests 194 | ``` 195 | 196 | Use pytest to run all tests 197 | ``` 198 | python -m pytest tests 199 | ``` 200 | 201 | ### Deployment 202 | 203 | Running behind an Nginx server 204 | 205 | ```bash 206 | wssh --address='127.0.0.1' --port=8888 --policy=reject 207 | ``` 208 | ```nginx 209 | # Nginx config example 210 | location / { 211 | proxy_pass http://127.0.0.1:8888; 212 | proxy_http_version 1.1; 213 | proxy_read_timeout 300; 214 | proxy_set_header Upgrade $http_upgrade; 215 | proxy_set_header Connection "upgrade"; 216 | proxy_set_header Host $http_host; 217 | proxy_set_header X-Real-IP $remote_addr; 218 | proxy_set_header X-Real-PORT $remote_port; 219 | } 220 | ``` 221 | 222 | Running as a standalone server 223 | ```bash 224 | wssh --port=8080 --sslport=4433 --certfile='cert.crt' --keyfile='cert.key' --xheaders=False --policy=reject 225 | ``` 226 | 227 | 228 | ### Tips 229 | 230 | * For whatever deployment choice you choose, don't forget to enable SSL. 231 | * By default plain http requests from a public network will be either redirected or blocked and being redirected takes precedence over being blocked. 232 | * Try to use reject policy as the missing host key policy along with your verified known_hosts, this will prevent man-in-the-middle attacks. The idea is that it checks the system host keys file("~/.ssh/known_hosts") and the application host keys file("./known_hosts") in order, if the ssh server's hostname is not found or the key is not matched, the connection will be aborted. 233 | 234 |
235 | -------------------------------------------------------------------------------- /webssh/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | import ssl 4 | import sys 5 | 6 | from tornado.options import define 7 | from webssh.policy import ( 8 | load_host_keys, get_policy_class, check_policy_setting 9 | ) 10 | from webssh.utils import ( 11 | to_ip_address, parse_origin_from_url, is_valid_encoding 12 | ) 13 | from webssh._version import __version__ 14 | 15 | 16 | def print_version(flag): 17 | if flag: 18 | print(__version__) 19 | sys.exit(0) 20 | 21 | 22 | define('address', default='', help='Listen address') 23 | define('port', type=int, default=8888, help='Listen port') 24 | define('ssladdress', default='', help='SSL listen address') 25 | define('sslport', type=int, default=4433, help='SSL listen port') 26 | define('certfile', default='', help='SSL certificate file') 27 | define('keyfile', default='', help='SSL private key file') 28 | define('debug', type=bool, default=False, help='Debug mode') 29 | define('policy', default='warning', 30 | help='Missing host key policy, reject|autoadd|warning') 31 | define('hostfile', default='', help='User defined host keys file') 32 | define('syshostfile', default='', help='System wide host keys file') 33 | define('tdstream', default='', help='Trusted downstream, separated by comma') 34 | define('redirect', type=bool, default=True, help='Redirecting http to https') 35 | define('fbidhttp', type=bool, default=True, 36 | help='Forbid public plain http incoming requests') 37 | define('xheaders', type=bool, default=True, help='Support xheaders') 38 | define('xsrf', type=bool, default=True, help='CSRF protection') 39 | define('origin', default='same', help='''Origin policy, 40 | 'same': same origin policy, matches host name and port number; 41 | 'primary': primary domain policy, matches primary domain only; 42 | '': custom domains policy, matches any domain in the list 43 | separated by comma; 44 | '*': wildcard policy, matches any domain, allowed in debug mode only.''') 45 | define('wpintvl', type=float, default=0, help='Websocket ping interval') 46 | define('timeout', type=float, default=3, help='SSH connection timeout') 47 | define('delay', type=float, default=3, help='The delay to call recycle_worker') 48 | define('maxconn', type=int, default=20, 49 | help='Maximum live connections (ssh sessions) per client') 50 | define('font', default='', help='custom font filename') 51 | define('encoding', default='utf-8', 52 | help='''The default character encoding of ssh servers. 53 | Example: --encoding='utf-8' to solve the problem with some switches&routers''') 54 | define('version', type=bool, help='Show version information', 55 | callback=print_version) 56 | 57 | 58 | base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 59 | font_dirs = ['webssh', 'static', 'css', 'fonts'] 60 | max_body_size = 1 * 1024 * 1024 61 | 62 | 63 | class Font(object): 64 | 65 | def __init__(self, filename, dirs): 66 | self.family = self.get_family(filename) 67 | self.url = self.get_url(filename, dirs) 68 | 69 | def get_family(self, filename): 70 | return filename.split('.')[0] 71 | 72 | def get_url(self, filename, dirs): 73 | return '/'.join(dirs + [filename]) 74 | 75 | 76 | def get_app_settings(options): 77 | settings = dict( 78 | template_path=os.path.join(base_dir, 'webssh', 'templates'), 79 | static_path=os.path.join(base_dir, 'webssh', 'static'), 80 | websocket_ping_interval=options.wpintvl, 81 | debug=options.debug, 82 | xsrf_cookies=options.xsrf, 83 | font=Font( 84 | get_font_filename(options.font, 85 | os.path.join(base_dir, *font_dirs)), 86 | font_dirs[1:] 87 | ), 88 | origin_policy=get_origin_setting(options) 89 | ) 90 | return settings 91 | 92 | 93 | def get_server_settings(options): 94 | settings = dict( 95 | xheaders=options.xheaders, 96 | max_body_size=max_body_size, 97 | trusted_downstream=get_trusted_downstream(options.tdstream) 98 | ) 99 | return settings 100 | 101 | 102 | def get_host_keys_settings(options): 103 | if not options.hostfile: 104 | host_keys_filename = os.path.join(base_dir, 'known_hosts') 105 | else: 106 | host_keys_filename = options.hostfile 107 | host_keys = load_host_keys(host_keys_filename) 108 | 109 | if not options.syshostfile: 110 | filename = os.path.expanduser('~/.ssh/known_hosts') 111 | else: 112 | filename = options.syshostfile 113 | system_host_keys = load_host_keys(filename) 114 | 115 | settings = dict( 116 | host_keys=host_keys, 117 | system_host_keys=system_host_keys, 118 | host_keys_filename=host_keys_filename 119 | ) 120 | return settings 121 | 122 | 123 | def get_policy_setting(options, host_keys_settings): 124 | policy_class = get_policy_class(options.policy) 125 | logging.info(policy_class.__name__) 126 | check_policy_setting(policy_class, host_keys_settings) 127 | return policy_class() 128 | 129 | 130 | def get_ssl_context(options): 131 | if not options.certfile and not options.keyfile: 132 | return None 133 | elif not options.certfile: 134 | raise ValueError('certfile is not provided') 135 | elif not options.keyfile: 136 | raise ValueError('keyfile is not provided') 137 | elif not os.path.isfile(options.certfile): 138 | raise ValueError('File {!r} does not exist'.format(options.certfile)) 139 | elif not os.path.isfile(options.keyfile): 140 | raise ValueError('File {!r} does not exist'.format(options.keyfile)) 141 | else: 142 | ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 143 | ssl_ctx.load_cert_chain(options.certfile, options.keyfile) 144 | return ssl_ctx 145 | 146 | 147 | def get_trusted_downstream(tdstream): 148 | result = set() 149 | for ip in tdstream.split(','): 150 | ip = ip.strip() 151 | if ip: 152 | to_ip_address(ip) 153 | result.add(ip) 154 | return result 155 | 156 | 157 | def get_origin_setting(options): 158 | if options.origin == '*': 159 | if not options.debug: 160 | raise ValueError( 161 | 'Wildcard origin policy is only allowed in debug mode.' 162 | ) 163 | else: 164 | return '*' 165 | 166 | origin = options.origin.lower() 167 | if origin in ['same', 'primary']: 168 | return origin 169 | 170 | origins = set() 171 | for url in origin.split(','): 172 | orig = parse_origin_from_url(url) 173 | if orig: 174 | origins.add(orig) 175 | 176 | if not origins: 177 | raise ValueError('Empty origin list') 178 | 179 | return origins 180 | 181 | 182 | def get_font_filename(font, font_dir): 183 | filenames = {f for f in os.listdir(font_dir) if not f.startswith('.') 184 | and os.path.isfile(os.path.join(font_dir, f))} 185 | if font: 186 | if font not in filenames: 187 | raise ValueError( 188 | 'Font file {!r} not found'.format(os.path.join(font_dir, font)) 189 | ) 190 | elif filenames: 191 | font = filenames.pop() 192 | 193 | return font 194 | 195 | 196 | def check_encoding_setting(encoding): 197 | if encoding and not is_valid_encoding(encoding): 198 | raise ValueError('Unknown character encoding {!r}.'.format(encoding)) 199 | -------------------------------------------------------------------------------- /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/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_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 | -------------------------------------------------------------------------------- /webssh/static/js/popper.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Federico Zivolo 2019 3 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 4 | */(function(e,t){'object'==typeof exports&&'undefined'!=typeof module?module.exports=t():'function'==typeof define&&define.amd?define(t):e.Popper=t()})(this,function(){'use strict';function e(e){return e&&'[object Function]'==={}.toString.call(e)}function t(e,t){if(1!==e.nodeType)return[];var o=e.ownerDocument.defaultView,n=o.getComputedStyle(e,null);return t?n[t]:n}function o(e){return'HTML'===e.nodeName?e:e.parentNode||e.host}function n(e){if(!e)return document.body;switch(e.nodeName){case'HTML':case'BODY':return e.ownerDocument.body;case'#document':return e.body;}var i=t(e),r=i.overflow,p=i.overflowX,s=i.overflowY;return /(auto|scroll|overlay)/.test(r+s+p)?e:n(o(e))}function r(e){return 11===e?pe:10===e?se:pe||se}function p(e){if(!e)return document.documentElement;for(var o=r(10)?document.body:null,n=e.offsetParent||null;n===o&&e.nextElementSibling;)n=(e=e.nextElementSibling).offsetParent;var i=n&&n.nodeName;return i&&'BODY'!==i&&'HTML'!==i?-1!==['TH','TD','TABLE'].indexOf(n.nodeName)&&'static'===t(n,'position')?p(n):n:e?e.ownerDocument.documentElement:document.documentElement}function s(e){var t=e.nodeName;return'BODY'!==t&&('HTML'===t||p(e.firstElementChild)===e)}function d(e){return null===e.parentNode?e:d(e.parentNode)}function a(e,t){if(!e||!e.nodeType||!t||!t.nodeType)return document.documentElement;var o=e.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_FOLLOWING,n=o?e:t,i=o?t:e,r=document.createRange();r.setStart(n,0),r.setEnd(i,0);var l=r.commonAncestorContainer;if(e!==l&&t!==l||n.contains(i))return s(l)?l:p(l);var f=d(e);return f.host?a(f.host,t):a(e,d(t).host)}function l(e){var t=1=o.clientWidth&&n>=o.clientHeight}),l=0a[e]&&!t.escapeWithReference&&(n=Q(f[o],a[e]-('right'===e?f.width:f.height))),le({},o,n)}};return l.forEach(function(e){var t=-1===['left','top'].indexOf(e)?'secondary':'primary';f=fe({},f,m[t](e))}),e.offsets.popper=f,e},priority:['left','right','top','bottom'],padding:5,boundariesElement:'scrollParent'},keepTogether:{order:400,enabled:!0,fn:function(e){var t=e.offsets,o=t.popper,n=t.reference,i=e.placement.split('-')[0],r=Z,p=-1!==['top','bottom'].indexOf(i),s=p?'right':'bottom',d=p?'left':'top',a=p?'width':'height';return o[s]r(n[s])&&(e.offsets.popper[d]=r(n[s])),e}},arrow:{order:500,enabled:!0,fn:function(e,o){var n;if(!K(e.instance.modifiers,'arrow','keepTogether'))return e;var i=o.element;if('string'==typeof i){if(i=e.instance.popper.querySelector(i),!i)return e;}else if(!e.instance.popper.contains(i))return console.warn('WARNING: `arrow.element` must be child of its popper element!'),e;var r=e.placement.split('-')[0],p=e.offsets,s=p.popper,d=p.reference,a=-1!==['left','right'].indexOf(r),l=a?'height':'width',f=a?'Top':'Left',m=f.toLowerCase(),h=a?'left':'top',c=a?'bottom':'right',u=S(i)[l];d[c]-us[c]&&(e.offsets.popper[m]+=d[m]+u-s[c]),e.offsets.popper=g(e.offsets.popper);var b=d[m]+d[l]/2-u/2,w=t(e.instance.popper),y=parseFloat(w['margin'+f],10),E=parseFloat(w['border'+f+'Width'],10),v=b-e.offsets.popper[m]-y-E;return v=ee(Q(s[l]-u,v),0),e.arrowElement=i,e.offsets.arrow=(n={},le(n,m,$(v)),le(n,h,''),n),e},element:'[x-arrow]'},flip:{order:600,enabled:!0,fn:function(e,t){if(W(e.instance.modifiers,'inner'))return e;if(e.flipped&&e.placement===e.originalPlacement)return e;var o=v(e.instance.popper,e.instance.reference,t.padding,t.boundariesElement,e.positionFixed),n=e.placement.split('-')[0],i=T(n),r=e.placement.split('-')[1]||'',p=[];switch(t.behavior){case ge.FLIP:p=[n,i];break;case ge.CLOCKWISE:p=G(n);break;case ge.COUNTERCLOCKWISE:p=G(n,!0);break;default:p=t.behavior;}return p.forEach(function(s,d){if(n!==s||p.length===d+1)return e;n=e.placement.split('-')[0],i=T(n);var a=e.offsets.popper,l=e.offsets.reference,f=Z,m='left'===n&&f(a.right)>f(l.left)||'right'===n&&f(a.left)f(l.top)||'bottom'===n&&f(a.top)f(o.right),g=f(a.top)f(o.bottom),b='left'===n&&h||'right'===n&&c||'top'===n&&g||'bottom'===n&&u,w=-1!==['top','bottom'].indexOf(n),y=!!t.flipVariations&&(w&&'start'===r&&h||w&&'end'===r&&c||!w&&'start'===r&&g||!w&&'end'===r&&u);(m||b||y)&&(e.flipped=!0,(m||b)&&(n=p[d+1]),y&&(r=z(r)),e.placement=n+(r?'-'+r:''),e.offsets.popper=fe({},e.offsets.popper,D(e.instance.popper,e.offsets.reference,e.placement)),e=P(e.instance.modifiers,e,'flip'))}),e},behavior:'flip',padding:5,boundariesElement:'viewport'},inner:{order:700,enabled:!1,fn:function(e){var t=e.placement,o=t.split('-')[0],n=e.offsets,i=n.popper,r=n.reference,p=-1!==['left','right'].indexOf(o),s=-1===['top','left'].indexOf(o);return i[p?'left':'top']=r[o]-(s?i[p?'width':'height']:0),e.placement=T(t),e.offsets.popper=g(i),e}},hide:{order:800,enabled:!0,fn:function(e){if(!K(e.instance.modifiers,'hide','preventOverflow'))return e;var t=e.offsets.reference,o=C(e.instance.modifiers,function(e){return'preventOverflow'===e.name}).boundaries;if(t.bottomo.right||t.top>o.bottom||t.rightwindow.devicePixelRatio||!me),c='bottom'===o?'top':'bottom',g='right'===n?'left':'right',b=H('transform');if(d='bottom'==c?'HTML'===l.nodeName?-l.clientHeight+h.bottom:-f.height+h.bottom:h.top,s='right'==g?'HTML'===l.nodeName?-l.clientWidth+h.right:-f.width+h.right:h.left,a&&b)m[b]='translate3d('+s+'px, '+d+'px, 0)',m[c]=0,m[g]=0,m.willChange='transform';else{var w='bottom'==c?-1:1,y='right'==g?-1:1;m[c]=d*w,m[g]=s*y,m.willChange=c+', '+g}var E={"x-placement":e.placement};return e.attributes=fe({},E,e.attributes),e.styles=fe({},m,e.styles),e.arrowStyles=fe({},e.offsets.arrow,e.arrowStyles),e},gpuAcceleration:!0,x:'bottom',y:'right'},applyStyle:{order:900,enabled:!0,fn:function(e){return j(e.instance.popper,e.styles),V(e.instance.popper,e.attributes),e.arrowElement&&Object.keys(e.arrowStyles).length&&j(e.arrowElement,e.arrowStyles),e},onLoad:function(e,t,o,n,i){var r=L(i,t,e,o.positionFixed),p=O(o.placement,r,t,e,o.modifiers.flip.boundariesElement,o.modifiers.flip.padding);return t.setAttribute('x-placement',p),j(t,{position:o.positionFixed?'fixed':'absolute'}),o},gpuAcceleration:void 0}}},ue}); 5 | //# sourceMappingURL=popper.min.js.map 6 | -------------------------------------------------------------------------------- /webssh/handler.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import logging 4 | import socket 5 | import struct 6 | import traceback 7 | import weakref 8 | import paramiko 9 | import tornado.web 10 | 11 | from concurrent.futures import ThreadPoolExecutor 12 | from tornado.ioloop import IOLoop 13 | from tornado.options import options 14 | from tornado.process import cpu_count 15 | from webssh.utils import ( 16 | is_valid_ip_address, is_valid_port, is_valid_hostname, to_bytes, to_str, 17 | to_int, to_ip_address, UnicodeType, is_ip_hostname, is_same_primary_domain, 18 | is_valid_encoding 19 | ) 20 | from webssh.worker import Worker, recycle_worker, clients 21 | 22 | try: 23 | from json.decoder import JSONDecodeError 24 | except ImportError: 25 | JSONDecodeError = ValueError 26 | 27 | try: 28 | from urllib.parse import urlparse 29 | except ImportError: 30 | from urlparse import urlparse 31 | 32 | 33 | DEFAULT_PORT = 22 34 | 35 | swallow_http_errors = True 36 | redirecting = None 37 | 38 | 39 | class InvalidValueError(Exception): 40 | pass 41 | 42 | 43 | class SSHClient(paramiko.SSHClient): 44 | 45 | def handler(self, title, instructions, prompt_list): 46 | answers = [] 47 | for prompt_, _ in prompt_list: 48 | prompt = prompt_.strip().lower() 49 | if prompt.startswith('password'): 50 | answers.append(self.password) 51 | elif prompt.startswith('verification'): 52 | answers.append(self.totp) 53 | else: 54 | raise ValueError('Unknown prompt: {}'.format(prompt_)) 55 | return answers 56 | 57 | def auth_interactive(self, username, handler): 58 | if not self.totp: 59 | raise ValueError('Need a verification code for 2fa.') 60 | self._transport.auth_interactive(username, handler) 61 | 62 | def _auth(self, username, password, pkey, *args): 63 | self.password = password 64 | saved_exception = None 65 | two_factor = False 66 | allowed_types = set() 67 | two_factor_types = {'keyboard-interactive', 'password'} 68 | 69 | if pkey is not None: 70 | logging.info('Trying publickey authentication') 71 | try: 72 | allowed_types = set( 73 | self._transport.auth_publickey(username, pkey) 74 | ) 75 | two_factor = allowed_types & two_factor_types 76 | if not two_factor: 77 | return 78 | except paramiko.SSHException as e: 79 | saved_exception = e 80 | 81 | if two_factor: 82 | logging.info('Trying publickey 2fa') 83 | return self.auth_interactive(username, self.handler) 84 | 85 | if password is not None: 86 | logging.info('Trying password authentication') 87 | try: 88 | self._transport.auth_password(username, password) 89 | return 90 | except paramiko.SSHException as e: 91 | saved_exception = e 92 | allowed_types = set(getattr(e, 'allowed_types', [])) 93 | two_factor = allowed_types & two_factor_types 94 | 95 | if two_factor: 96 | logging.info('Trying password 2fa') 97 | return self.auth_interactive(username, self.handler) 98 | 99 | assert saved_exception is not None 100 | raise saved_exception 101 | 102 | 103 | class PrivateKey(object): 104 | 105 | max_length = 16384 # rough number 106 | 107 | tag_to_name = { 108 | 'RSA': 'RSA', 109 | 'DSA': 'DSS', 110 | 'EC': 'ECDSA', 111 | 'OPENSSH': 'Ed25519' 112 | } 113 | 114 | def __init__(self, privatekey, password=None, filename=''): 115 | self.privatekey = privatekey 116 | self.filename = filename 117 | self.password = password 118 | self.check_length() 119 | self.iostr = io.StringIO(privatekey) 120 | self.last_exception = None 121 | 122 | def check_length(self): 123 | if len(self.privatekey) > self.max_length: 124 | raise InvalidValueError('Invalid key length.') 125 | 126 | def parse_name(self, iostr, tag_to_name): 127 | name = None 128 | for line_ in iostr: 129 | line = line_.strip() 130 | if line and line.startswith('-----BEGIN ') and \ 131 | line.endswith(' PRIVATE KEY-----'): 132 | lst = line.split(' ') 133 | if len(lst) == 4: 134 | tag = lst[1] 135 | if tag: 136 | name = tag_to_name.get(tag) 137 | if name: 138 | break 139 | return name, len(line_) 140 | 141 | def get_specific_pkey(self, name, offset, password): 142 | self.iostr.seek(offset) 143 | logging.debug('Reset offset to {}.'.format(offset)) 144 | 145 | logging.debug('Try parsing it as {} type key'.format(name)) 146 | pkeycls = getattr(paramiko, name+'Key') 147 | pkey = None 148 | 149 | try: 150 | pkey = pkeycls.from_private_key(self.iostr, password=password) 151 | except paramiko.PasswordRequiredException: 152 | raise InvalidValueError('Need a passphrase to decrypt the key.') 153 | except (paramiko.SSHException, ValueError) as exc: 154 | self.last_exception = exc 155 | logging.debug(str(exc)) 156 | 157 | return pkey 158 | 159 | def get_pkey_obj(self): 160 | logging.info('Parsing private key {!r}'.format(self.filename)) 161 | name, length = self.parse_name(self.iostr, self.tag_to_name) 162 | if not name: 163 | raise InvalidValueError('Invalid key {}.'.format(self.filename)) 164 | 165 | offset = self.iostr.tell() - length 166 | password = to_bytes(self.password) if self.password else None 167 | pkey = self.get_specific_pkey(name, offset, password) 168 | 169 | if pkey is None and name == 'Ed25519': 170 | for name in ['RSA', 'ECDSA', 'DSS']: 171 | pkey = self.get_specific_pkey(name, offset, password) 172 | if pkey: 173 | break 174 | 175 | if pkey: 176 | return pkey 177 | 178 | logging.error(str(self.last_exception)) 179 | msg = 'Invalid key' 180 | if self.password: 181 | msg += ' or wrong passphrase "{}" for decrypting it.'.format( 182 | self.password) 183 | raise InvalidValueError(msg) 184 | 185 | 186 | class MixinHandler(object): 187 | 188 | custom_headers = { 189 | 'Server': 'TornadoServer' 190 | } 191 | 192 | html = ('{code} {reason}{code} ' 193 | '{reason}') 194 | 195 | def initialize(self, loop=None): 196 | self.check_request() 197 | self.loop = loop 198 | self.origin_policy = self.settings.get('origin_policy') 199 | 200 | def check_request(self): 201 | context = self.request.connection.context 202 | result = self.is_forbidden(context, self.request.host_name) 203 | self._transforms = [] 204 | if result: 205 | self.set_status(403) 206 | self.finish( 207 | self.html.format(code=self._status_code, reason=self._reason) 208 | ) 209 | elif result is False: 210 | to_url = self.get_redirect_url( 211 | self.request.host_name, options.sslport, self.request.uri 212 | ) 213 | self.redirect(to_url, permanent=True) 214 | else: 215 | self.context = context 216 | 217 | def check_origin(self, origin): 218 | if self.origin_policy == '*': 219 | return True 220 | 221 | parsed_origin = urlparse(origin) 222 | netloc = parsed_origin.netloc.lower() 223 | logging.debug('netloc: {}'.format(netloc)) 224 | 225 | host = self.request.headers.get('Host') 226 | logging.debug('host: {}'.format(host)) 227 | 228 | if netloc == host: 229 | return True 230 | 231 | if self.origin_policy == 'same': 232 | return False 233 | elif self.origin_policy == 'primary': 234 | return is_same_primary_domain(netloc.rsplit(':', 1)[0], 235 | host.rsplit(':', 1)[0]) 236 | else: 237 | return origin in self.origin_policy 238 | 239 | def is_forbidden(self, context, hostname): 240 | ip = context.address[0] 241 | lst = context.trusted_downstream 242 | ip_address = None 243 | 244 | if lst and ip not in lst: 245 | logging.warning( 246 | 'IP {!r} not found in trusted downstream {!r}'.format(ip, lst) 247 | ) 248 | return True 249 | 250 | if context._orig_protocol == 'http': 251 | if redirecting and not is_ip_hostname(hostname): 252 | ip_address = to_ip_address(ip) 253 | if not ip_address.is_private: 254 | # redirecting 255 | return False 256 | 257 | if options.fbidhttp: 258 | if ip_address is None: 259 | ip_address = to_ip_address(ip) 260 | if not ip_address.is_private: 261 | logging.warning('Public plain http request is forbidden.') 262 | return True 263 | 264 | def get_redirect_url(self, hostname, port, uri): 265 | port = '' if port == 443 else ':%s' % port 266 | return 'https://{}{}{}'.format(hostname, port, uri) 267 | 268 | def set_default_headers(self): 269 | for header in self.custom_headers.items(): 270 | self.set_header(*header) 271 | 272 | def get_value(self, name): 273 | value = self.get_argument(name) 274 | if not value: 275 | raise InvalidValueError('Missing value {}'.format(name)) 276 | return value 277 | 278 | def get_context_addr(self): 279 | return self.context.address[:2] 280 | 281 | def get_client_addr(self): 282 | if options.xheaders: 283 | return self.get_real_client_addr() or self.get_context_addr() 284 | else: 285 | return self.get_context_addr() 286 | 287 | def get_real_client_addr(self): 288 | ip = self.request.remote_ip 289 | 290 | if ip == self.request.headers.get('X-Real-Ip'): 291 | port = self.request.headers.get('X-Real-Port') 292 | elif ip in self.request.headers.get('X-Forwarded-For', ''): 293 | port = self.request.headers.get('X-Forwarded-Port') 294 | else: 295 | # not running behind an nginx server 296 | return 297 | 298 | port = to_int(port) 299 | if port is None or not is_valid_port(port): 300 | # fake port 301 | port = 65535 302 | 303 | return (ip, port) 304 | 305 | 306 | class NotFoundHandler(MixinHandler, tornado.web.ErrorHandler): 307 | 308 | def initialize(self): 309 | super(NotFoundHandler, self).initialize() 310 | 311 | def prepare(self): 312 | raise tornado.web.HTTPError(404) 313 | 314 | 315 | class IndexHandler(MixinHandler, tornado.web.RequestHandler): 316 | 317 | executor = ThreadPoolExecutor(max_workers=cpu_count()*5) 318 | 319 | def initialize(self, loop, policy, host_keys_settings): 320 | super(IndexHandler, self).initialize(loop) 321 | self.policy = policy 322 | self.host_keys_settings = host_keys_settings 323 | self.ssh_client = self.get_ssh_client() 324 | self.debug = self.settings.get('debug', False) 325 | self.font = self.settings.get('font', '') 326 | self.result = dict(id=None, status=None, encoding=None) 327 | 328 | def write_error(self, status_code, **kwargs): 329 | if swallow_http_errors and self.request.method == 'POST': 330 | exc_info = kwargs.get('exc_info') 331 | if exc_info: 332 | reason = getattr(exc_info[1], 'log_message', None) 333 | if reason: 334 | self._reason = reason 335 | self.result.update(status=self._reason) 336 | self.set_status(200) 337 | self.finish(self.result) 338 | else: 339 | super(IndexHandler, self).write_error(status_code, **kwargs) 340 | 341 | def get_ssh_client(self): 342 | ssh = SSHClient() 343 | ssh._system_host_keys = self.host_keys_settings['system_host_keys'] 344 | ssh._host_keys = self.host_keys_settings['host_keys'] 345 | ssh._host_keys_filename = self.host_keys_settings['host_keys_filename'] 346 | ssh.set_missing_host_key_policy(self.policy) 347 | return ssh 348 | 349 | def get_privatekey(self): 350 | name = 'privatekey' 351 | lst = self.request.files.get(name) 352 | if lst: 353 | # multipart form 354 | filename = lst[0]['filename'] 355 | data = lst[0]['body'] 356 | value = self.decode_argument(data, name=name).strip() 357 | else: 358 | # urlencoded form 359 | value = self.get_argument(name, u'') 360 | filename = '' 361 | 362 | return value, filename 363 | 364 | def get_hostname(self): 365 | value = self.get_value('hostname') 366 | if not (is_valid_hostname(value) or is_valid_ip_address(value)): 367 | raise InvalidValueError('Invalid hostname: {}'.format(value)) 368 | return value 369 | 370 | def get_port(self): 371 | value = self.get_argument('port', u'') 372 | if not value: 373 | return DEFAULT_PORT 374 | 375 | port = to_int(value) 376 | if port is None or not is_valid_port(port): 377 | raise InvalidValueError('Invalid port: {}'.format(value)) 378 | return port 379 | 380 | def lookup_hostname(self, hostname, port): 381 | key = hostname if port == 22 else '[{}]:{}'.format(hostname, port) 382 | 383 | if self.ssh_client._system_host_keys.lookup(key) is None: 384 | if self.ssh_client._host_keys.lookup(key) is None: 385 | raise tornado.web.HTTPError( 386 | 403, 'Connection to {}:{} is not allowed.'.format( 387 | hostname, port) 388 | ) 389 | 390 | def get_args(self): 391 | hostname = self.get_hostname() 392 | port = self.get_port() 393 | username = self.get_value('username') 394 | password = self.get_argument('password', u'') 395 | privatekey, filename = self.get_privatekey() 396 | passphrase = self.get_argument('passphrase', u'') 397 | totp = self.get_argument('totp', u'') 398 | 399 | if isinstance(self.policy, paramiko.RejectPolicy): 400 | self.lookup_hostname(hostname, port) 401 | 402 | if privatekey: 403 | pkey = PrivateKey(privatekey, passphrase, filename).get_pkey_obj() 404 | else: 405 | pkey = None 406 | 407 | self.ssh_client.totp = totp 408 | args = (hostname, port, username, password, pkey) 409 | logging.debug(args) 410 | 411 | return args 412 | 413 | def parse_encoding(self, data): 414 | try: 415 | encoding = to_str(data.strip(), 'ascii') 416 | except UnicodeDecodeError: 417 | return 418 | 419 | if is_valid_encoding(encoding): 420 | return encoding 421 | 422 | def get_default_encoding(self, ssh): 423 | commands = [ 424 | '$SHELL -ilc "locale charmap"', 425 | '$SHELL -ic "locale charmap"' 426 | ] 427 | 428 | for command in commands: 429 | try: 430 | _, stdout, _ = ssh.exec_command(command, 431 | get_pty=True, 432 | timeout=1) 433 | except paramiko.SSHException as exc: 434 | logging.info(str(exc)) 435 | else: 436 | try: 437 | data = stdout.read() 438 | except socket.timeout: 439 | pass 440 | else: 441 | logging.debug('{!r} => {!r}'.format(command, data)) 442 | result = self.parse_encoding(data) 443 | if result: 444 | return result 445 | 446 | logging.warning('Could not detect the default encoding.') 447 | return 'utf-8' 448 | 449 | def ssh_connect(self, args): 450 | ssh = self.ssh_client 451 | dst_addr = args[:2] 452 | logging.info('Connecting to {}:{}'.format(*dst_addr)) 453 | 454 | try: 455 | ssh.connect(*args, timeout=options.timeout) 456 | except socket.error: 457 | raise ValueError('Unable to connect to {}:{}'.format(*dst_addr)) 458 | except paramiko.BadAuthenticationType: 459 | raise ValueError('Bad authentication type.') 460 | except paramiko.AuthenticationException: 461 | raise ValueError('Authentication failed.') 462 | except paramiko.BadHostKeyException: 463 | raise ValueError('Bad host key.') 464 | 465 | term = self.get_argument('term', u'') or u'xterm' 466 | chan = ssh.invoke_shell(term=term) 467 | chan.setblocking(0) 468 | worker = Worker(self.loop, ssh, chan, dst_addr) 469 | worker.encoding = options.encoding if options.encoding else \ 470 | self.get_default_encoding(ssh) 471 | return worker 472 | 473 | def check_origin(self): 474 | event_origin = self.get_argument('_origin', u'') 475 | header_origin = self.request.headers.get('Origin') 476 | origin = event_origin or header_origin 477 | 478 | if origin: 479 | if not super(IndexHandler, self).check_origin(origin): 480 | raise tornado.web.HTTPError( 481 | 403, 'Cross origin operation is not allowed.' 482 | ) 483 | 484 | if not event_origin and self.origin_policy != 'same': 485 | self.set_header('Access-Control-Allow-Origin', origin) 486 | 487 | def head(self): 488 | pass 489 | 490 | def get(self): 491 | self.render('index.html', debug=self.debug, font=self.font) 492 | 493 | @tornado.gen.coroutine 494 | def post(self): 495 | if self.debug and self.get_argument('error', u''): 496 | # for testing purpose only 497 | raise ValueError('Uncaught exception') 498 | 499 | ip, port = self.get_client_addr() 500 | workers = clients.get(ip, {}) 501 | if workers and len(workers) >= options.maxconn: 502 | raise tornado.web.HTTPError(403, 'Too many live connections.') 503 | 504 | self.check_origin() 505 | 506 | try: 507 | args = self.get_args() 508 | except InvalidValueError as exc: 509 | raise tornado.web.HTTPError(400, str(exc)) 510 | 511 | future = self.executor.submit(self.ssh_connect, args) 512 | 513 | try: 514 | worker = yield future 515 | except (ValueError, paramiko.SSHException) as exc: 516 | logging.error(traceback.format_exc()) 517 | self.result.update(status=str(exc)) 518 | else: 519 | if not workers: 520 | clients[ip] = workers 521 | worker.src_addr = (ip, port) 522 | workers[worker.id] = worker 523 | self.loop.call_later(options.delay, recycle_worker, worker) 524 | self.result.update(id=worker.id, encoding=worker.encoding) 525 | 526 | self.write(self.result) 527 | 528 | 529 | class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): 530 | 531 | def initialize(self, loop): 532 | super(WsockHandler, self).initialize(loop) 533 | self.worker_ref = None 534 | 535 | def open(self): 536 | self.src_addr = self.get_client_addr() 537 | logging.info('Connected from {}:{}'.format(*self.src_addr)) 538 | 539 | workers = clients.get(self.src_addr[0]) 540 | if not workers: 541 | self.close(reason='Websocket authentication failed.') 542 | return 543 | 544 | try: 545 | worker_id = self.get_value('id') 546 | except (tornado.web.MissingArgumentError, InvalidValueError) as exc: 547 | self.close(reason=str(exc)) 548 | else: 549 | worker = workers.get(worker_id) 550 | if worker: 551 | workers[worker_id] = None 552 | self.set_nodelay(True) 553 | worker.set_handler(self) 554 | self.worker_ref = weakref.ref(worker) 555 | self.loop.add_handler(worker.fd, worker, IOLoop.READ) 556 | else: 557 | self.close(reason='Websocket authentication failed.') 558 | 559 | def on_message(self, message): 560 | logging.debug('{!r} from {}:{}'.format(message, *self.src_addr)) 561 | worker = self.worker_ref() 562 | if not worker: 563 | # The worker has likely been closed. Do not process. 564 | logging.debug( 565 | "received message to closed worker from {}:{}".format( 566 | *self.src_addr 567 | ) 568 | ) 569 | self.close(reason='No worker found') 570 | return 571 | 572 | if worker.closed: 573 | self.close(reason='Worker closed') 574 | return 575 | 576 | try: 577 | msg = json.loads(message) 578 | except JSONDecodeError: 579 | return 580 | 581 | if not isinstance(msg, dict): 582 | return 583 | 584 | resize = msg.get('resize') 585 | if resize and len(resize) == 2: 586 | try: 587 | worker.chan.resize_pty(*resize) 588 | except (TypeError, struct.error, paramiko.SSHException): 589 | pass 590 | 591 | data = msg.get('data') 592 | if data and isinstance(data, UnicodeType): 593 | worker.data_to_dst.append(data) 594 | worker.on_write() 595 | 596 | def on_close(self): 597 | logging.info('Disconnected from {}:{}'.format(*self.src_addr)) 598 | if not self.close_reason: 599 | self.close_reason = 'client disconnected' 600 | 601 | worker = self.worker_ref() if self.worker_ref else None 602 | if worker: 603 | worker.close(reason=self.close_reason) 604 | -------------------------------------------------------------------------------- /webssh/static/js/main.js: -------------------------------------------------------------------------------- 1 | /*jslint browser:true */ 2 | 3 | var jQuery; 4 | var wssh = {}; 5 | 6 | 7 | (function() { 8 | // For FormData without getter and setter 9 | var proto = FormData.prototype, 10 | data = {}; 11 | 12 | if (!proto.get) { 13 | proto.get = function (name) { 14 | if (data[name] === undefined) { 15 | var input = document.querySelector('input[name="' + name + '"]'), 16 | value; 17 | if (input) { 18 | if (input.type === 'file') { 19 | value = input.files[0]; 20 | } else { 21 | value = input.value; 22 | } 23 | data[name] = value; 24 | } 25 | } 26 | return data[name]; 27 | }; 28 | } 29 | 30 | if (!proto.set) { 31 | proto.set = function (name, value) { 32 | data[name] = value; 33 | }; 34 | } 35 | 36 | document.querySelector('#sshlinkBtn').addEventListener("click", updateSSHlink); 37 | }()); 38 | 39 | function updateSSHlink() { 40 | var thisPageProtocol = window.location.protocol; 41 | var thisPageUrl = window.location.host; 42 | 43 | var hostnamestr = document.getElementById("hostname").value; 44 | var portstr = document.getElementById("port").value; 45 | if (portstr == "") { 46 | portstr = "22" 47 | } 48 | var usrnamestr = document.getElementById("username").value; 49 | if (usrnamestr == "") { 50 | usrnamestr = "root" 51 | } 52 | var passwdstr = document.getElementById("password").value; 53 | var passwdstrAfterBase64 = window.btoa(passwdstr); 54 | 55 | var initcmdstr = document.getElementById("initcmd").value; 56 | var initcmdstrAfterURI = encodeURIComponent(initcmdstr); 57 | 58 | var sshlinkstr; 59 | sshlinkstr = thisPageProtocol+"//"+thisPageUrl+"/?hostname="+hostnamestr+"&port="+portstr+"&username="+usrnamestr+"&password="+passwdstrAfterBase64+"&command="+initcmdstrAfterURI; 60 | 61 | document.getElementById("sshlink").innerHTML = sshlinkstr; 62 | } 63 | 64 | jQuery(function($){ 65 | var status = $('#status'), 66 | button = $('.btn-primary'), 67 | form_container = $('.form-container'), 68 | waiter = $('#waiter'), 69 | term_type = $('#term'), 70 | style = {}, 71 | default_title = 'WebSSH', 72 | title_element = document.querySelector('title'), 73 | form_id = '#connect', 74 | debug = document.querySelector(form_id).noValidate, 75 | custom_font = document.fonts ? document.fonts.values().next().value : undefined, 76 | default_fonts, 77 | DISCONNECTED = 0, 78 | CONNECTING = 1, 79 | CONNECTED = 2, 80 | state = DISCONNECTED, 81 | messages = {1: 'This client is connecting ...', 2: 'This client is already connnected.'}, 82 | key_max_size = 16384, 83 | fields = ['hostname', 'port', 'username'], 84 | form_keys = fields.concat(['password', 'totp']), 85 | opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'], 86 | url_form_data = {}, 87 | url_opts_data = {}, 88 | validated_form_data, 89 | event_origin, 90 | hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/; 91 | 92 | 93 | function store_items(names, data) { 94 | var i, name, value; 95 | 96 | for (i = 0; i < names.length; i++) { 97 | name = names[i]; 98 | value = data.get(name); 99 | if (value){ 100 | window.localStorage.setItem(name, value); 101 | } 102 | } 103 | } 104 | 105 | 106 | function restore_items(names) { 107 | var i, name, value; 108 | 109 | for (i=0; i < names.length; i++) { 110 | name = names[i]; 111 | value = window.localStorage.getItem(name); 112 | if (value) { 113 | $('#'+name).val(value); 114 | } 115 | } 116 | } 117 | 118 | 119 | function populate_form(data) { 120 | var names = form_keys.concat(['passphrase']), 121 | i, name; 122 | 123 | for (i=0; i < names.length; i++) { 124 | name = names[i]; 125 | $('#'+name).val(data.get(name)); 126 | } 127 | } 128 | 129 | 130 | function get_object_length(object) { 131 | return Object.keys(object).length; 132 | } 133 | 134 | 135 | function decode_uri_component(uri) { 136 | try { 137 | return decodeURIComponent(uri); 138 | } catch(e) { 139 | console.error(e); 140 | } 141 | return ''; 142 | } 143 | 144 | 145 | function decode_password(encoded) { 146 | try { 147 | return window.atob(encoded); 148 | } catch (e) { 149 | console.error(e); 150 | } 151 | return null; 152 | } 153 | 154 | 155 | function parse_url_data(string, form_keys, opts_keys, form_map, opts_map) { 156 | var i, pair, key, val, 157 | arr = string.split('&'); 158 | 159 | for (i = 0; i < arr.length; i++) { 160 | pair = arr[i].split('='); 161 | key = pair[0].trim().toLowerCase(); 162 | val = pair.slice(1).join('=').trim(); 163 | 164 | if (form_keys.indexOf(key) >= 0) { 165 | form_map[key] = val; 166 | } else if (opts_keys.indexOf(key) >=0) { 167 | opts_map[key] = val; 168 | } 169 | } 170 | 171 | if (form_map.password) { 172 | form_map.password = decode_password(form_map.password); 173 | } 174 | } 175 | 176 | 177 | function parse_xterm_style() { 178 | var text = $('.xterm-helpers style').text(); 179 | var arr = text.split('xterm-normal-char{width:'); 180 | style.width = parseFloat(arr[1]); 181 | arr = text.split('div{height:'); 182 | style.height = parseFloat(arr[1]); 183 | } 184 | 185 | 186 | function get_cell_size(term) { 187 | style.width = term._core._renderService._renderer.dimensions.actualCellWidth; 188 | style.height = term._core._renderService._renderer.dimensions.actualCellHeight; 189 | } 190 | 191 | 192 | function toggle_fullscreen(term) { 193 | $('#terminal .terminal').toggleClass('fullscreen'); 194 | term.fitAddon.fit(); 195 | } 196 | 197 | 198 | function current_geometry(term) { 199 | if (!style.width || !style.height) { 200 | try { 201 | get_cell_size(term); 202 | } catch (TypeError) { 203 | parse_xterm_style(); 204 | } 205 | } 206 | 207 | var cols = parseInt(window.innerWidth / style.width, 10) - 1; 208 | var rows = parseInt(window.innerHeight / style.height, 10); 209 | return {'cols': cols, 'rows': rows}; 210 | } 211 | 212 | 213 | function resize_terminal(term) { 214 | var geometry = current_geometry(term); 215 | term.on_resize(geometry.cols, geometry.rows); 216 | } 217 | 218 | 219 | function set_backgound_color(term, color) { 220 | term.setOption('theme', { 221 | background: color 222 | }); 223 | } 224 | 225 | function set_font_color(term, color) { 226 | term.setOption('theme', { 227 | foreground: color 228 | }); 229 | } 230 | 231 | function custom_font_is_loaded() { 232 | if (!custom_font) { 233 | console.log('No custom font specified.'); 234 | } else { 235 | console.log('Status of custom font ' + custom_font.family + ': ' + custom_font.status); 236 | if (custom_font.status === 'loaded') { 237 | return true; 238 | } 239 | if (custom_font.status === 'unloaded') { 240 | return false; 241 | } 242 | } 243 | } 244 | 245 | function update_font_family(term) { 246 | if (term.font_family_updated) { 247 | console.log('Already using custom font family'); 248 | return; 249 | } 250 | 251 | if (!default_fonts) { 252 | default_fonts = term.getOption('fontFamily'); 253 | } 254 | 255 | if (custom_font_is_loaded()) { 256 | var new_fonts = custom_font.family + ', ' + default_fonts; 257 | term.setOption('fontFamily', new_fonts); 258 | term.font_family_updated = true; 259 | console.log('Using custom font family ' + new_fonts); 260 | } 261 | } 262 | 263 | 264 | function reset_font_family(term) { 265 | if (!term.font_family_updated) { 266 | console.log('Already using default font family'); 267 | return; 268 | } 269 | 270 | if (default_fonts) { 271 | term.setOption('fontFamily', default_fonts); 272 | term.font_family_updated = false; 273 | console.log('Using default font family ' + default_fonts); 274 | } 275 | } 276 | 277 | 278 | function format_geometry(cols, rows) { 279 | return JSON.stringify({'cols': cols, 'rows': rows}); 280 | } 281 | 282 | 283 | function read_as_text_with_decoder(file, callback, decoder) { 284 | var reader = new window.FileReader(); 285 | 286 | if (decoder === undefined) { 287 | decoder = new window.TextDecoder('utf-8', {'fatal': true}); 288 | } 289 | 290 | reader.onload = function() { 291 | var text; 292 | try { 293 | text = decoder.decode(reader.result); 294 | } catch (TypeError) { 295 | console.log('Decoding error happened.'); 296 | } finally { 297 | if (callback) { 298 | callback(text); 299 | } 300 | } 301 | }; 302 | 303 | reader.onerror = function (e) { 304 | console.error(e); 305 | }; 306 | 307 | reader.readAsArrayBuffer(file); 308 | } 309 | 310 | 311 | function read_as_text_with_encoding(file, callback, encoding) { 312 | var reader = new window.FileReader(); 313 | 314 | if (encoding === undefined) { 315 | encoding = 'utf-8'; 316 | } 317 | 318 | reader.onload = function() { 319 | if (callback) { 320 | callback(reader.result); 321 | } 322 | }; 323 | 324 | reader.onerror = function (e) { 325 | console.error(e); 326 | }; 327 | 328 | reader.readAsText(file, encoding); 329 | } 330 | 331 | 332 | function read_file_as_text(file, callback, decoder) { 333 | if (!window.TextDecoder) { 334 | read_as_text_with_encoding(file, callback, decoder); 335 | } else { 336 | read_as_text_with_decoder(file, callback, decoder); 337 | } 338 | } 339 | 340 | 341 | function reset_wssh() { 342 | var name; 343 | 344 | for (name in wssh) { 345 | if (wssh.hasOwnProperty(name) && name !== 'connect') { 346 | delete wssh[name]; 347 | } 348 | } 349 | } 350 | 351 | 352 | function log_status(text, to_populate) { 353 | console.log(text); 354 | status.html(text.split('\n').join('
')); 355 | 356 | if (to_populate && validated_form_data) { 357 | populate_form(validated_form_data); 358 | validated_form_data = undefined; 359 | } 360 | 361 | if (waiter.css('display') !== 'none') { 362 | waiter.hide(); 363 | } 364 | 365 | if (form_container.css('display') === 'none') { 366 | form_container.show(); 367 | } 368 | } 369 | 370 | 371 | function ajax_complete_callback(resp) { 372 | button.prop('disabled', false); 373 | 374 | if (resp.status !== 200) { 375 | log_status(resp.status + ': ' + resp.statusText, true); 376 | state = DISCONNECTED; 377 | return; 378 | } 379 | 380 | var msg = resp.responseJSON; 381 | if (!msg.id) { 382 | log_status(msg.status, true); 383 | state = DISCONNECTED; 384 | return; 385 | } 386 | 387 | var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'), 388 | join = (ws_url[ws_url.length-1] === '/' ? '' : '/'), 389 | url = ws_url + join + 'ws?id=' + msg.id, 390 | sock = new window.WebSocket(url), 391 | encoding = 'utf-8', 392 | decoder = window.TextDecoder ? new window.TextDecoder(encoding) : encoding, 393 | terminal = document.getElementById('terminal'), 394 | termOptions = { 395 | cursorBlink: true, 396 | theme: { 397 | background: url_opts_data.bgcolor || 'black', 398 | foreground: url_opts_data.fontcolor || 'white', 399 | cursor: url_opts_data.cursor || url_opts_data.fontcolor || 'white' 400 | } 401 | }; 402 | 403 | if (url_opts_data.fontsize) { 404 | var fontsize = window.parseInt(url_opts_data.fontsize); 405 | if (fontsize && fontsize > 0) { 406 | termOptions.fontSize = fontsize; 407 | } 408 | } 409 | 410 | var term = new window.Terminal(termOptions); 411 | 412 | term.fitAddon = new window.FitAddon.FitAddon(); 413 | term.loadAddon(term.fitAddon); 414 | 415 | console.log(url); 416 | if (!msg.encoding) { 417 | console.log('Unable to detect the default encoding of your server'); 418 | msg.encoding = encoding; 419 | } else { 420 | console.log('The deault encoding of your server is ' + msg.encoding); 421 | } 422 | 423 | function term_write(text) { 424 | if (term) { 425 | term.write(text); 426 | if (!term.resized) { 427 | resize_terminal(term); 428 | term.resized = true; 429 | } 430 | } 431 | } 432 | 433 | function set_encoding(new_encoding) { 434 | // for console use 435 | if (!new_encoding) { 436 | console.log('An encoding is required'); 437 | return; 438 | } 439 | 440 | if (!window.TextDecoder) { 441 | decoder = new_encoding; 442 | encoding = decoder; 443 | console.log('Set encoding to ' + encoding); 444 | } else { 445 | try { 446 | decoder = new window.TextDecoder(new_encoding); 447 | encoding = decoder.encoding; 448 | console.log('Set encoding to ' + encoding); 449 | } catch (RangeError) { 450 | console.log('Unknown encoding ' + new_encoding); 451 | return false; 452 | } 453 | } 454 | } 455 | 456 | wssh.set_encoding = set_encoding; 457 | 458 | if (url_opts_data.encoding) { 459 | if (set_encoding(url_opts_data.encoding) === false) { 460 | set_encoding(msg.encoding); 461 | } 462 | } else { 463 | set_encoding(msg.encoding); 464 | } 465 | 466 | 467 | wssh.geometry = function() { 468 | // for console use 469 | var geometry = current_geometry(term); 470 | console.log('Current window geometry: ' + JSON.stringify(geometry)); 471 | }; 472 | 473 | wssh.send = function(data) { 474 | // for console use 475 | if (!sock) { 476 | console.log('Websocket was already closed'); 477 | return; 478 | } 479 | 480 | if (typeof data !== 'string') { 481 | console.log('Only string is allowed'); 482 | return; 483 | } 484 | 485 | try { 486 | JSON.parse(data); 487 | sock.send(data); 488 | } catch (SyntaxError) { 489 | data = data.trim() + '\r'; 490 | sock.send(JSON.stringify({'data': data})); 491 | } 492 | }; 493 | 494 | wssh.reset_encoding = function() { 495 | // for console use 496 | if (encoding === msg.encoding) { 497 | console.log('Already reset to ' + msg.encoding); 498 | } else { 499 | set_encoding(msg.encoding); 500 | } 501 | }; 502 | 503 | wssh.resize = function(cols, rows) { 504 | // for console use 505 | if (term === undefined) { 506 | console.log('Terminal was already destroryed'); 507 | return; 508 | } 509 | 510 | var valid_args = false; 511 | 512 | if (cols > 0 && rows > 0) { 513 | var geometry = current_geometry(term); 514 | if (cols <= geometry.cols && rows <= geometry.rows) { 515 | valid_args = true; 516 | } 517 | } 518 | 519 | if (!valid_args) { 520 | console.log('Unable to resize terminal to geometry: ' + format_geometry(cols, rows)); 521 | } else { 522 | term.on_resize(cols, rows); 523 | } 524 | }; 525 | 526 | wssh.set_bgcolor = function(color) { 527 | set_backgound_color(term, color); 528 | }; 529 | 530 | wssh.set_fontcolor = function(color) { 531 | set_font_color(term, color); 532 | }; 533 | 534 | wssh.custom_font = function() { 535 | update_font_family(term); 536 | }; 537 | 538 | wssh.default_font = function() { 539 | reset_font_family(term); 540 | }; 541 | 542 | term.on_resize = function(cols, rows) { 543 | if (cols !== this.cols || rows !== this.rows) { 544 | console.log('Resizing terminal to geometry: ' + format_geometry(cols, rows)); 545 | this.resize(cols, rows); 546 | sock.send(JSON.stringify({'resize': [cols, rows]})); 547 | } 548 | }; 549 | 550 | term.onData(function(data) { 551 | // console.log(data); 552 | sock.send(JSON.stringify({'data': data})); 553 | }); 554 | 555 | sock.onopen = function() { 556 | term.open(terminal); 557 | toggle_fullscreen(term); 558 | update_font_family(term); 559 | term.focus(); 560 | state = CONNECTED; 561 | title_element.text = url_opts_data.title || default_title; 562 | if (url_opts_data.command) { 563 | setTimeout(function () { 564 | sock.send(JSON.stringify({'data': url_opts_data.command+'\r'})); 565 | }, 500); 566 | } 567 | }; 568 | 569 | sock.onmessage = function(msg) { 570 | read_file_as_text(msg.data, term_write, decoder); 571 | }; 572 | 573 | sock.onerror = function(e) { 574 | console.error(e); 575 | }; 576 | 577 | sock.onclose = function(e) { 578 | term.dispose(); 579 | term = undefined; 580 | sock = undefined; 581 | reset_wssh(); 582 | log_status(e.reason, true); 583 | state = DISCONNECTED; 584 | default_title = 'WebSSH'; 585 | title_element.text = default_title; 586 | }; 587 | 588 | $(window).resize(function(){ 589 | if (term) { 590 | resize_terminal(term); 591 | } 592 | }); 593 | } 594 | 595 | 596 | function wrap_object(opts) { 597 | var obj = {}; 598 | 599 | obj.get = function(attr) { 600 | return opts[attr] || ''; 601 | }; 602 | 603 | obj.set = function(attr, val) { 604 | opts[attr] = val; 605 | }; 606 | 607 | return obj; 608 | } 609 | 610 | 611 | function clean_data(data) { 612 | var i, attr, val; 613 | var attrs = form_keys.concat(['privatekey', 'passphrase']); 614 | 615 | for (i = 0; i < attrs.length; i++) { 616 | attr = attrs[i]; 617 | val = data.get(attr); 618 | if (typeof val === 'string') { 619 | data.set(attr, val.trim()); 620 | } 621 | } 622 | } 623 | 624 | 625 | function validate_form_data(data) { 626 | clean_data(data); 627 | 628 | var hostname = data.get('hostname'), 629 | port = data.get('port'), 630 | username = data.get('username'), 631 | pk = data.get('privatekey'), 632 | result = { 633 | valid: false, 634 | data: data, 635 | title: '' 636 | }, 637 | errors = [], size; 638 | 639 | if (!hostname) { 640 | errors.push('Value of hostname is required.'); 641 | } else { 642 | if (!hostname_tester.test(hostname)) { 643 | errors.push('Invalid hostname: ' + hostname); 644 | } 645 | } 646 | 647 | if (!port) { 648 | port = 22; 649 | } else { 650 | if (!(port > 0 && port <= 65535)) { 651 | errors.push('Invalid port: ' + port); 652 | } 653 | } 654 | 655 | if (!username) { 656 | errors.push('Value of username is required.'); 657 | } 658 | 659 | if (pk) { 660 | size = pk.size || pk.length; 661 | if (size > key_max_size) { 662 | errors.push('Invalid private key: ' + pk.name || ''); 663 | } 664 | } 665 | 666 | if (!errors.length || debug) { 667 | result.valid = true; 668 | result.title = username + '@' + hostname + ':' + port; 669 | } 670 | result.errors = errors; 671 | 672 | return result; 673 | } 674 | 675 | // Fix empty input file ajax submission error for safari 11.x 676 | function disable_file_inputs(inputs) { 677 | var i, input; 678 | 679 | for (i = 0; i < inputs.length; i++) { 680 | input = inputs[i]; 681 | if (input.files.length === 0) { 682 | input.setAttribute('disabled', ''); 683 | } 684 | } 685 | } 686 | 687 | 688 | function enable_file_inputs(inputs) { 689 | var i; 690 | 691 | for (i = 0; i < inputs.length; i++) { 692 | inputs[i].removeAttribute('disabled'); 693 | } 694 | } 695 | 696 | 697 | function connect_without_options() { 698 | // use data from the form 699 | var form = document.querySelector(form_id), 700 | inputs = form.querySelectorAll('input[type="file"]'), 701 | url = form.action, 702 | data, pk; 703 | 704 | disable_file_inputs(inputs); 705 | data = new FormData(form); 706 | pk = data.get('privatekey'); 707 | enable_file_inputs(inputs); 708 | 709 | function ajax_post() { 710 | status.text(''); 711 | button.prop('disabled', true); 712 | 713 | $.ajax({ 714 | url: url, 715 | type: 'post', 716 | data: data, 717 | complete: ajax_complete_callback, 718 | cache: false, 719 | contentType: false, 720 | processData: false 721 | }); 722 | } 723 | 724 | var result = validate_form_data(data); 725 | if (!result.valid) { 726 | log_status(result.errors.join('\n')); 727 | return; 728 | } 729 | 730 | if (pk && pk.size && !debug) { 731 | read_file_as_text(pk, function(text) { 732 | if (text === undefined) { 733 | log_status('Invalid private key: ' + pk.name); 734 | } else { 735 | ajax_post(); 736 | } 737 | }); 738 | } else { 739 | ajax_post(); 740 | } 741 | 742 | return result; 743 | } 744 | 745 | 746 | function connect_with_options(data) { 747 | // use data from the arguments 748 | var form = document.querySelector(form_id), 749 | url = data.url || form.action, 750 | _xsrf = form.querySelector('input[name="_xsrf"]'); 751 | 752 | var result = validate_form_data(wrap_object(data)); 753 | if (!result.valid) { 754 | log_status(result.errors.join('\n')); 755 | return; 756 | } 757 | 758 | data.term = term_type.val(); 759 | data._xsrf = _xsrf.value; 760 | if (event_origin) { 761 | data._origin = event_origin; 762 | } 763 | 764 | status.text(''); 765 | button.prop('disabled', true); 766 | 767 | $.ajax({ 768 | url: url, 769 | type: 'post', 770 | data: data, 771 | complete: ajax_complete_callback 772 | }); 773 | 774 | return result; 775 | } 776 | 777 | 778 | function connect(hostname, port, username, password, privatekey, passphrase, totp) { 779 | // for console use 780 | var result, opts; 781 | 782 | if (state !== DISCONNECTED) { 783 | console.log(messages[state]); 784 | return; 785 | } 786 | 787 | if (hostname === undefined) { 788 | result = connect_without_options(); 789 | } else { 790 | if (typeof hostname === 'string') { 791 | opts = { 792 | hostname: hostname, 793 | port: port, 794 | username: username, 795 | password: password, 796 | privatekey: privatekey, 797 | passphrase: passphrase, 798 | totp: totp 799 | }; 800 | } else { 801 | opts = hostname; 802 | } 803 | 804 | result = connect_with_options(opts); 805 | } 806 | 807 | if (result) { 808 | state = CONNECTING; 809 | default_title = result.title; 810 | if (hostname) { 811 | validated_form_data = result.data; 812 | } 813 | store_items(fields, result.data); 814 | } 815 | } 816 | 817 | wssh.connect = connect; 818 | 819 | $(form_id).submit(function(event){ 820 | event.preventDefault(); 821 | connect(); 822 | }); 823 | 824 | 825 | function cross_origin_connect(event) 826 | { 827 | console.log(event.origin); 828 | var prop = 'connect', 829 | args; 830 | 831 | try { 832 | args = JSON.parse(event.data); 833 | } catch (SyntaxError) { 834 | args = event.data.split('|'); 835 | } 836 | 837 | if (!Array.isArray(args)) { 838 | args = [args]; 839 | } 840 | 841 | try { 842 | event_origin = event.origin; 843 | wssh[prop].apply(wssh, args); 844 | } finally { 845 | event_origin = undefined; 846 | } 847 | } 848 | 849 | window.addEventListener('message', cross_origin_connect, false); 850 | 851 | if (document.fonts) { 852 | document.fonts.ready.then( 853 | function () { 854 | if (custom_font_is_loaded() === false) { 855 | document.body.style.fontFamily = custom_font.family; 856 | } 857 | } 858 | ); 859 | } 860 | 861 | 862 | parse_url_data( 863 | decode_uri_component(window.location.search.substring(1)) + '&' + decode_uri_component(window.location.hash.substring(1)), 864 | form_keys, opts_keys, url_form_data, url_opts_data 865 | ); 866 | // console.log(url_form_data); 867 | // console.log(url_opts_data); 868 | 869 | if (url_opts_data.term) { 870 | term_type.val(url_opts_data.term); 871 | } 872 | 873 | if (url_form_data.password === null) { 874 | log_status('Password via url must be encoded in base64.'); 875 | } else { 876 | if (get_object_length(url_form_data)) { 877 | waiter.show(); 878 | connect(url_form_data); 879 | } else { 880 | restore_items(fields); 881 | form_container.show(); 882 | } 883 | } 884 | 885 | }); 886 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------