├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build_executable.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── changelog.md ├── contributing.md ├── index.md ├── termpair_architecture.excalidraw └── termpair_architecture.png ├── makefile ├── mkdocs.yml ├── noxfile.py ├── requirements.in ├── requirements.txt ├── setup.py ├── termpair ├── Terminal.py ├── __init__.py ├── constants.py ├── encryption.py ├── frontend_src │ ├── README.md │ ├── craco.config.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ ├── src │ │ ├── App.test.tsx │ │ ├── App.tsx │ │ ├── BottomBar.tsx │ │ ├── CopyCommand.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── LandingPageContent.tsx │ │ ├── TopBar.tsx │ │ ├── constants.tsx │ │ ├── encryption.tsx │ │ ├── events.tsx │ │ ├── global.d.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── logo.png │ │ ├── react-app-env.d.ts │ │ ├── serviceWorker.ts │ │ ├── types.tsx │ │ ├── utils.tsx │ │ ├── websocketMessageHandler.tsx │ │ └── xtermUtils.tsx │ ├── tailwind.config.js │ ├── tsconfig.json │ └── yarn.lock ├── main.py ├── server.py ├── server_websocket_subprotocol_handlers.py ├── share.py └── utils.py ├── termpair_browser.gif └── tests ├── test_e2e.py └── test_server.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E501, E203, W503 # line length, whitespace before ':' 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report a bug or unexpected behavior. 4 | 5 | --- 6 | 7 | 10 | 11 | **Describe the bug** 12 | 16 | 17 | **How to reproduce** 18 | 19 | 20 | **Expected behavior** 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea or new feature for this project 4 | 5 | --- 6 | 7 | **How would this feature be useful?** 8 | 9 | 10 | **Describe the solution you'd like** 11 | 12 | 13 | **Describe alternatives you've considered** 14 | 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | * [] I have added an entry to `CHANGELOG.md` 3 | 4 | ## Summary of changes 5 | 6 | ## Test plan 7 | 8 | Tested by running 9 | ``` 10 | # command(s) to exercise these changes 11 | ``` 12 | -------------------------------------------------------------------------------- /.github/workflows/build_executable.yml: -------------------------------------------------------------------------------- 1 | name: build executable 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | default-python: "3.10" 8 | 9 | jobs: 10 | build-executable: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | python-version: ["3.10"] 16 | node-version: [12.x] 17 | include: 18 | - os: macos-latest 19 | python-version: "3.10" 20 | node-version: 12.x 21 | buildname: "mac" 22 | - os: ubuntu-latest 23 | buildname: "linux" 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Set up Node ${{ matrix.node-version }} 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | - name: Build React app 36 | run: | 37 | cd termpair/frontend_src 38 | yarn install 39 | yarn build 40 | - name: Upgrade pip, Install pex 41 | run: | 42 | python -m pip install --upgrade pip 43 | python -m pip install nox 44 | - name: Build pex executable 45 | run: | 46 | nox --error-on-missing-interpreters --non-interactive --session build_executable-${{ matrix.python-version }} 47 | - name: Upload ${{ matrix.buildname }} executable 48 | # if: github.ref == 'refs/heads/master' 49 | uses: actions/upload-artifact@v1 50 | with: 51 | name: termpair_${{ matrix.buildname }} 52 | path: ./build/termpair.pex 53 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions 2 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | push: 9 | 10 | env: 11 | default-python: "3.10" 12 | 13 | jobs: 14 | python-lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ env.default-python }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ env.default-python }} 22 | - name: Upgrade pip, Install nox 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install nox 26 | - name: Lint 27 | run: | 28 | nox --error-on-missing-interpreters --non-interactive --session lint-${{ env.default-python }} 29 | 30 | run-tests: 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | matrix: 34 | os: [ubuntu-latest] 35 | python-version: ["3.10"] 36 | node-version: [12.x] 37 | include: 38 | - os: macos-latest 39 | python-version: "3.10" 40 | node-version: 12.x 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v2 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Set up Node ${{ matrix.node-version }} 49 | uses: actions/setup-node@v2 50 | with: 51 | node-version: ${{ matrix.node-version }} 52 | - name: Build React app 53 | run: | 54 | cd termpair/frontend_src 55 | yarn install 56 | yarn build 57 | - name: Upgrade pip, Install nox 58 | run: | 59 | python -m pip install --upgrade pip 60 | python -m pip install nox 61 | - name: Execute Python Tests 62 | run: | 63 | nox --non-interactive --session test-${{ matrix.python-version }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | frontend_build 2 | *.pyc 3 | venv/ 4 | bin/ 5 | build/ 6 | develop-eggs/ 7 | dist/ 8 | eggs/ 9 | lib/ 10 | lib64/ 11 | parts/ 12 | sdist/ 13 | var/ 14 | *.egg-info/ 15 | .installed.cfg 16 | *.egg 17 | *.eggs 18 | .mypy_cache/ 19 | 20 | site 21 | # dependencies 22 | *node_modules* 23 | .pnp 24 | .pnp.js 25 | 26 | # testing 27 | /coverage 28 | 29 | # production 30 | /build 31 | termpair/frontend_build/ 32 | 33 | # misc 34 | .DS_Store 35 | .env.local 36 | .env.development.local 37 | .env.test.local 38 | .env.production.local 39 | 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | precache-manifest* 44 | *asset-manifest* 45 | service-worker* 46 | 47 | 48 | # openssl files 49 | *.crt 50 | *.cert 51 | *.key 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/#installation for installation instructions 3 | # See https://pre-commit.com/hooks.html for more hooks 4 | # 5 | # use `git commit --no-verify` to disable git hooks for this commit 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.0.1 10 | hooks: 11 | - id: end-of-file-fixer 12 | - id: check-added-large-files 13 | - id: trailing-whitespace 14 | - id: check-yaml 15 | - repo: https://github.com/PyCQA/isort 16 | rev: 5.10.1 17 | hooks: 18 | - id: isort 19 | args: ['--profile','black'] 20 | - repo: https://github.com/psf/black 21 | rev: 22.3.0 22 | hooks: 23 | - id: black 24 | - repo: https://gitlab.com/PyCQA/flake8 25 | rev: 4.0.1 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: [ 29 | 'flake8-bugbear==21.11.29' 30 | ] 31 | # mypy args: 32 | # must include --ignore-missing-imports for mypy. It is included by default 33 | # if no arguments are supplied, but we must supply it ourselves since we 34 | # specify args 35 | # cannot use --warn-unused-ignores because it conflicts with 36 | # --ignore-missing-imports 37 | - repo: https://github.com/pre-commit/mirrors-mypy 38 | rev: v0.930 39 | hooks: 40 | - id: mypy 41 | args: ['--ignore-missing-imports', '--strict-equality','--no-implicit-optional'] 42 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "[python]": { 4 | "editor.formatOnSave": true 5 | }, 6 | "[json]": { 7 | "editor.formatOnSave": true 8 | }, 9 | "[typescriptreact]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | "editor.formatOnSave": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.1.5 2 | 3 | - Update dependencies to fix a Python 3.10 incompatibility 4 | - Do not require a specific version of python to run the pex executable 5 | - Add pre-commit and run on all files 6 | 7 | ## 0.3.1.4 8 | 9 | - Show user a clear error if the browser is not running in a secure context 10 | 11 | ## 0.3.1.3 12 | 13 | - [bugfix] Require command in command line. `termpair` now results in an error instead of displaying no output and returning 0. 14 | - Upgrade JavaScript dependencies 15 | 16 | ## 0.3.1.1 17 | 18 | - [feature] add small, dark grey outline around the terminal 19 | - [bugfix] center the terminal instead of left aligning it 20 | - [bugfix] better text spacing in bottom status bar 21 | 22 | ## 0.3.1.0 23 | 24 | - [feature] Store user input values of the terminal id, key, and host, and restore them when the page loads 25 | - [bugfix] Ensure width fits on mobile devies 26 | 27 | ## 0.3.0.0 28 | 29 | **Breaking API Changes** 30 | In this version, TermPair clients from previous versions cannot connect to this TermPair server 31 | 32 | - Use new key sharing scheme: Different keys used in different directions; keys rotated 33 | - [bugfix] Terminal dimensions in browser match upon initial connection, instead of after resizing 34 | - Allow static site to route terminal traffic through other server. If static site is detected, user can enter the terminal id and server url in the browser UI. 35 | - Allow Terminal ID and initial encryption key to be entered on landing page 36 | - Add additional random string to each encrypted message 37 | - Display version in webpage 38 | - Add troubleshooting instructions to webpage 39 | - Rename `--no-browser-control` argument of `termpair share` to `--read-only` 40 | 41 | ## 0.2.0.0 42 | 43 | - Add ability to copy+paste using keystrokes (copy with ctrl+shift+c or ctrl+shift+x, and paste with ctrl+shift+v) 44 | - Add a status bar to the bottom of the page 45 | - Show terminal dimensions in bottom status bar 46 | - Add toasts to notify user of various events 47 | - Fix bug where connected browsers do not have their websocket connection closed when terminal closes, which makes it look like the terminal is still connected when it is not. 48 | - Improve error messages, in particular if there is no server running 49 | - Fixed bug where websocket connection is briefly accepted regardless of whether a valid terminal id is provided to `/terminal/{terminal_id}`. Instead of returning a JSON object with the TermPair version, a 404 error is now returned. 50 | - [dev] migrate codebase to typescript 51 | - [dev] use React functional component instead of class component for main application 52 | 53 | ## 0.1.1.1 54 | 55 | - Fix server bug when using SSL certs (#44) 56 | 57 | ## 0.1.1.0 58 | 59 | - Ensure error message is printed to browser's terminal if site is not served in a secure context (#39) 60 | - Make default TermPair terminal client port 8000 to match default server port (#38) 61 | - Always display port to connect to in browser's connection instructions 62 | 63 | ## 0.1.0.2 64 | 65 | - Change default sharing port to None due to difficulties sharing to port 80/reverse proxies 66 | - Print port in web UI's sharing command 67 | 68 | ## 0.1.0.1 69 | 70 | - Remove debug message from server 71 | 72 | ## 0.1.0.0 73 | 74 | - Pin dependencies 75 | - Change default sharing port to 8000 to match default server port 76 | 77 | ## 0.0.1.3 78 | 79 | - Upgrade xtermjs 80 | 81 | ## 0.0.1.2 82 | 83 | - Update landing page when terminal id is not provided 84 | 85 | ## 0.0.1.1 86 | 87 | - Fix pipx install link in frontend 88 | 89 | ## 0.0.1.0 90 | 91 | - Add end-to-end encryption 92 | - Change `termpair serve` to allow browser control by default, and update CLI API by replacing `-a` flag with `-n` flag. 93 | 94 | ## 0.0.3.0 95 | 96 | - Use FastAPI on backend and update UI 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | This short document should give you some hints to get started with contributing to TermPair. 4 | 5 | ## Getting started 6 | 7 | First, fork the repo and clone it to your computer, then read the section you're interested in. 8 | 9 | ### Server 10 | 11 | Install [nox](https://pypi.org/project/nox/). 12 | 13 | You can run the server from source with: 14 | 15 | ``` 16 | nox -s serve-3.10 17 | ``` 18 | 19 | ### Terminal Client 20 | 21 | Install [nox](https://pypi.org/project/nox/). 22 | 23 | You can run the terminal client from source with: 24 | 25 | ``` 26 | nox -s share-3.10 27 | ``` 28 | 29 | You can pass additional arguments like this 30 | 31 | ``` 32 | nox -s share-3.10 -- 33 | ``` 34 | 35 | ### Frontend Web App 36 | 37 | First, get [yarn](https://yarnpkg.com/en/). 38 | 39 | Next go to the directory `termpair/frontend_src` and run 40 | 41 | ```bash 42 | yarn install 43 | ``` 44 | 45 | to install dependencies. 46 | 47 | You can run the development server and hot reload changes. This is the easiest way to quickly statically serve the app from source. 48 | 49 | ```bash 50 | yarn start 51 | ``` 52 | 53 | To build the production code, run: 54 | 55 | ```bash 56 | yarn build 57 | ``` 58 | 59 | The static web app will be compiled to `termpair/termpair_build/`. TermPair will then serve this with `nox -s serve`. 60 | 61 | You can also serve locally with 62 | 63 | ``` 64 | $ cd termpair/termpair/frontend_build 65 | $ python3 -m http.server 7999 --bind 127.0.0.1 66 | # Serves at http://127.0.01:7999 67 | ``` 68 | 69 | or deploy to GitHub pages, Vercel, etc. 70 | 71 | ## Releasing new versions to PyPI 72 | 73 | ``` 74 | nox -s publish 75 | ``` 76 | 77 | ## Proposing changes 78 | 79 | If you've found a bug, have a feature request, or would like to contribute documentation, here's what you can do to have your change merged in: 80 | 81 | 1. (Recommended) If the problem is non-trivial, you should [open an issue][issue] to discuss it 82 | 2. Work on a separate branch, and make sure tests pass before pushing them to the remote. 83 | 3. [Open a pull request][pr] with your changes. 84 | 85 | [issue]: https://github.com/cs01/termpair/issues/new 86 | [pr]: https://github.com/cs01/termpair/compare 87 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | ENV ROOT=/usr/src/app 5 | 6 | WORKDIR ${ROOT} 7 | RUN curl https://bootstrap.pypa.io/get-pip.py | python3.6 8 | ADD . ${ROOT} 9 | RUN python -m pip install . 10 | RUN pip install gunicorn 11 | CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "termpair.server:app"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Chad Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Things to include in the built package (besides the packages defined in setup.py) 2 | 3 | graft termpair/frontend_build 4 | 5 | include README.md 6 | include requirements.txt 7 | include LICENSE 8 | 9 | prune termpair/frontend_src 10 | prune docs 11 | prune tests 12 | prune .vscode 13 | 14 | exclude .flake8 15 | exclude Dockerfile 16 | exclude CHANGELOG.md 17 | exclude CONTRIBUTING.md 18 | exclude requirements.in 19 | exclude termpair_browser.gif 20 | exclude makefile 21 | exclude mkdocs.yml 22 | exclude mypy.ini 23 | exclude noxfile.py 24 | exclude package.json 25 | exclude termpair_browser.png 26 | exclude termpair_terminal.png 27 | exclude yarn.lock 28 | exclude .pre-commit-config.yaml 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

View and control remote terminals from your browser with end-to-end encryption

4 |

5 | PyPI version 6 | 7 | 8 | PyPI version 9 |

10 |
11 | 12 | ## What is TermPair? 13 | 14 | TermPair lets developers securely share and control terminals in real time. 15 | 16 | You can **try it now** at [https://chadsmith.dev/termpair](https://chadsmith.dev/termpair) or **check out the [YouTube Demo](https://www.youtube.com/watch?v=HF0UX4smrKk)**. 17 |
18 | 19 |
20 | 21 | ## Features 22 | * Share unix terminals in real time 23 | * Type from the terminal or browser; both are kept in sync 24 | * Multiple browsers can connect simultaneously 25 | * Browser permissions can be read/write or read only 26 | * Server cannot read terminal data even if it wanted to, since it is encrypted with AES 128 bit encryption 27 | * Secure web environment required (https) 28 | * Optional static-site hosting -- build the web app yourself to ensure the integrity of the web app ([example](https://cs01.github.io/termpair/connect/)) 29 | * Broadcasting terminal's dimensions are sent to the browser in realtime so rendering always matches 30 | 31 | ## Usage 32 | 33 | First start the TermPair server with `termpair serve`, or use the one already running at [https://chadsmith.dev/termpair](https://chadsmith.dev/termpair). 34 | 35 | The server is used to route encrypted data between terminals and connected browsers — it doesn't actually start sharing any terminals on its own. 36 | 37 | ``` 38 | > termpair serve 39 | ``` 40 | 41 | Now that you have the server running, you can share your terminal by running `termpair share`. 42 | 43 | This connects your terminal to the server, and allows browsers to access the terminal through the server. 44 | 45 | ``` 46 | > termpair share 47 | -------------------------------------------------------------------------------- 48 | Connection established with end-to-end encryption 🔒 49 | 50 | Shareable link: http://localhost:8000/?terminal_id=d58ff4eed5aa9425e944abe63214382e#g8hSgHnDaBtiWKTeH4I0Ow== 51 | 52 | Terminal ID: d58ff4eed5aa9425e944abe63214382e 53 | Secret encryption key: g8hSgHnDaBtiWKTeH4I0Ow== 54 | TermPair Server URL: http://localhost:8000/ 55 | 56 | Type 'exit' or close terminal to stop sharing. 57 | -------------------------------------------------------------------------------- 58 | ``` 59 | 60 | The URL printed contains a unique terminal ID and encryption key. You can share the URL with whoever you like. **Anyone who has it can access your terminal while the `termpair share` process is running,** so be sure you trust the person you are sharing the link with. 61 | 62 | By default, the process that is shared is a new process running the current shell, determined by the `$SHELL` evironment variable. 63 | 64 | The server multicasts terminal output to all browsers that connect to the session. 65 | 66 | ## System Requirements 67 | 68 | Python: 3.6+ 69 | 70 | Operating Systems: Linux, macOS 71 | 72 | ## Installation 73 | 74 | ### Option 1: Download executable 75 | Download from the [release](https://github.com/cs01/termpair/releases) page. You may have to run `chmod +x` before you can run it. 76 | 77 | ### Option 2: Install Using `pipx` or `pip` 78 | You can install using [pipx](https://github.com/pipxproject/pipx), which installs Python applications in isolated environments (recommended): 79 | 80 | ``` 81 | > pipx install termpair 82 | ``` 83 | 84 | or install with [pip](https://pip.pypa.io/en/stable/) 85 | 86 | ``` 87 | > pip install termpair 88 | ``` 89 | 90 | Note: Make sure the TermPair server you are broadcasting to is running the same major version as the broadcasting terminal (see `termpair --version`). 91 | 92 | ## Run With Latest Version 93 | 94 | You can also use [pipx](https://github.com/pipxproject/pipx) to directly run the latest version without installing: 95 | 96 | Serve: 97 | ``` 98 | > pipx run termpair serve 99 | ``` 100 | 101 | Then share: 102 | ``` 103 | > pipx run termpair share 104 | ``` 105 | 106 | Note: Make sure the TermPair server you are broadcasting to is running the same major version as the broadcasting terminal (see `pipx run termpair --version`). You can specify the version with `pipx run --spec termpair==$VERSION termpair ...`. 107 | 108 | ## Security 109 | 110 | TermPair uses end-to-end encryption for all terminal input and output, meaning the server *never* has access to the raw input or output of the terminal, nor does it have access to encryption keys (other than the https connection). 111 | 112 | The browser must be running in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). This typically means running with secure http traffic (https) or on localhost. 113 | 114 | ### Running Client from Source 115 | For extra assurance the source code is secure, you can broadcast your terminal from source. 116 | See [CONTRIBUTING.md](https://github.com/cs01/termpair/blob/master/CONTRIBUTING.md) for more information. 117 | 118 | ### Static Hosting 119 | TermPair supports statically serving the JavaScript web app. This way you can easily run the web app from source for extra assurance the web app is secure. 120 | 121 | In this arrangement, you can build the TermPair web app yourself and host on your computer, or statically host on something like GitHub pages or Vercel. That way you can guarantee the server is not providing a malicious JavaScript web app since you built it from the source. 122 | 123 | When you open the web app without a TermPair server running, you specify the Terminal ID, encryption key, and TermPair server host to connect to. 124 | 125 | You can try it out or just see what it looks like with a GitHub page from this project, [https://cs01.github.io/termpair/connect/](https://cs01.github.io/termpair/connect/). 126 | 127 | See [CONTRIBUTING.md](https://github.com/cs01/termpair/blob/master/CONTRIBUTING.md) for more information. 128 | 129 | ## How it Works 130 | 131 |
132 | 133 | 134 |
135 | 136 | TermPair consists of three pieces: 137 | 138 | 1. server 139 | 2. terminal client 140 | 3. JavaScript web app running in browser client(s) 141 | 142 | ### Server 143 | First, the termpair server is started (`termpair serve`). The server acts as a router that blindly forwards encrypted data between TermPair terminal clients and connected browsers. The server listens for termpair websocket connections from unix terminal clients, and maintains a mapping to any connected browsers. 144 | 145 | ### Terminal Client 146 | When a user wants to share their terminal, they run `termpair share` to start the client. The TermPair client registers this session with the server, then [forks](https://docs.python.org/3/library/pty.html#pty.fork) and starts a psuedo-terminal (pty) with the desired process, usually a shell like `bash` or `zsh`. TermPair reads data from the pty's file descriptor as it becomes available, then writes it to ther real terminal's stdout, where it is printed like normal. However, it also encrypts this output and sends it to the server via a websocket. 147 | 148 | ### Encryption 149 | The TermPair client creates three 128 bit AES encryption keys when it starts: 150 | * The first is used to encrypt the terminal's output before sending it to the server. 151 | * The second is used by the browser before sending user input to the server. 152 | * The third is a "bootstrap" key used by the browser to decrypt the initial connection response from the broadcasting terminal, which contains the above two keys encrypted with this third key. The browser obtains this bootstrap key via a [part of the url](https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement/hash) that the server does not have access to, or via manual user input. A public key exchange like Diffie-Hellman was not used since multiple browsers can connect to the terminal, which would increase the complexity of TermPair's codebase. Still, DH in some form may be considered in the future. 153 | 154 | ### Web App 155 | The TermPair client provides the user with a unique URL for the duration of the shaing session. That URL points to the TermPair web application (TypeScript/React) that sets up a websocket connection to receive and send the encrypted terminal data. When data is received, it is decrypted and written to a browser-based terminal. 156 | 157 | When a user types in the browser's terminal, it is encrypted in the browser with key #2, sent to the server, forwarded from the server to the terminal, then decrypted in the terminal by TermPair. Finally, the TermPair client writes it to the pty's file descriptor, as if it were being typed directly to the terminal. 158 | 159 | AES keys #1 and #2 get rotated after either key has sent 2^20 (1048576) messages. The AES initialization vector (IV) values increment monotonically to ensure they are never reused. 160 | 161 | ## Serving with NGINX 162 | Running behind an nginx proxy can be done with the following configuration. 163 | 164 | The TermPair server must be started already. This is usually done as a [systemd service](#running-as-a-systemd-service). The port being run on must be specified in the `upstream` configuration. 165 | 166 | ```nginx 167 | upstream termpair_app { 168 | # Make sure the port matches the port you are running on 169 | server 127.0.0.1:8000; 170 | } 171 | 172 | server { 173 | server_name myserver.com; 174 | 175 | # I recommend Certbot if you don't have SSL set up 176 | listen 443 ssl; 177 | ssl_certificate fullchain.pem; 178 | ssl_certificate_key privkey.pem; 179 | 180 | location /termpair/ { 181 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 182 | proxy_set_header Host $host; 183 | 184 | proxy_pass http://termpair_app/; 185 | 186 | proxy_http_version 1.1; 187 | proxy_set_header Upgrade $http_upgrade; 188 | proxy_set_header Connection "upgrade"; 189 | } 190 | } 191 | ``` 192 | 193 | ## Running as a systemd service 194 | If you use systemd to manage services, here is an example configuration you can start with. 195 | 196 | This configuration assumes you've installed TermPair to `/home/$USER/.local/bin/termpair` and saved the file to `/etc/systemd/system/termpair.service`. 197 | 198 | ```toml 199 | # /etc/systemd/system/termpair.service 200 | 201 | # https://www.freedesktop.org/software/systemd/man/systemd.service.html 202 | [Unit] 203 | Description= 204 | After=network.target 205 | 206 | [Service] 207 | User=$USER 208 | Group=www-data 209 | WorkingDirectory=/var/www/termpair/ 210 | PermissionsStartOnly=true 211 | ExecStart=/home/$USER/.local/bin/termpair serve --port 8000 212 | ExecStop= 213 | Restart=on-failure 214 | RestartSec=1s 215 | 216 | [Install] 217 | WantedBy=multi-user.target 218 | ``` 219 | 220 | After saving, you can use `systemctl` to start your `systemd` service: 221 | ``` 222 | sudo systemctl daemon-reload 223 | sudo systemctl enable termpair.service 224 | sudo systemctl restart termpair 225 | ``` 226 | 227 | ## CLI API 228 | 229 | ``` 230 | > termpair --help 231 | usage: termpair [-h] [--version] {share,serve} ... 232 | 233 | View and control remote terminals from your browser 234 | 235 | positional arguments: 236 | {share,serve} 237 | 238 | optional arguments: 239 | -h, --help show this help message and exit 240 | --version 241 | ``` 242 | 243 | To start the TermPair server: 244 | ``` 245 | > termpair serve --help 246 | usage: termpair serve [-h] [--port PORT] [--host HOST] [--certfile CERTFILE] 247 | [--keyfile KEYFILE] 248 | 249 | Run termpair server to route messages between unix terminals and browsers. Run 250 | this before connecting any clients. It is recommended to encrypt communication 251 | by using SSL/TLS. To generate an SSL certificate and private key, run `openssl 252 | req -newkey rsa:2048 -nodes -keyout host.key -x509 -days 365 -out host.crt`. 253 | To skip questions and use defaults, add the `-batch` flag. You can ignore 254 | warnings about self-signed certificates since you know you just made it. Then 255 | use them, pass the '--certfile' and '--keyfile' arguments. 256 | 257 | optional arguments: 258 | -h, --help show this help message and exit 259 | --port PORT, -p PORT Port to run the server on (default: 8000) 260 | --host HOST Host to run the server on (0.0.0.0 exposes publicly) 261 | (default: localhost) 262 | --certfile CERTFILE, -c CERTFILE 263 | Path to SSL certificate file (commonly .crt extension) 264 | (default: None) 265 | --keyfile KEYFILE, -k KEYFILE 266 | Path to SSL private key .key file (commonly .key 267 | extension) (default: None) 268 | ``` 269 | 270 | To share a terminal using the TermPair client: 271 | ``` 272 | > termpair share --help 273 | usage: termpair share [-h] [--cmd CMD] [--port PORT] [--host HOST] [--read-only] 274 | [--open-browser] 275 | 276 | Share your terminal session with one or more browsers. A termpair server must be 277 | running before using this command. 278 | 279 | optional arguments: 280 | -h, --help show this help message and exit 281 | --cmd CMD The command to run in this TermPair session. Defaults to 282 | the SHELL environment variable (default: /bin/bash) 283 | --port PORT, -p PORT port server is running on (default: 8000) 284 | --host HOST host server is running on (default: http://localhost) 285 | --read-only, -r Do not allow browsers to write to the terminal (default: 286 | False) 287 | --open-browser, -b Open a browser tab to the terminal after you start 288 | sharing (default: False) 289 | ``` 290 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/termpair_architecture.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "ellipse", 8 | "version": 319, 9 | "versionNonce": 1229052490, 10 | "isDeleted": false, 11 | "id": "IALiHKOmluotiryCW6iS_", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 1, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 781, 19 | "y": 90, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "transparent", 22 | "width": 408.19101123595505, 23 | "height": 400.6964011996001, 24 | "seed": 648518015, 25 | "groupIds": [], 26 | "strokeSharpness": "sharp", 27 | "boundElementIds": [ 28 | "QDEk0R4uM0Jk487w7_U_C", 29 | "xtf852WRDZbeXkDCLUj44" 30 | ] 31 | }, 32 | { 33 | "type": "text", 34 | "version": 125, 35 | "versionNonce": 1588720625, 36 | "isDeleted": false, 37 | "id": "YJLRL4XtrgP9ggs3iQzb3", 38 | "fillStyle": "hachure", 39 | "strokeWidth": 1, 40 | "strokeStyle": "solid", 41 | "roughness": 1, 42 | "opacity": 100, 43 | "angle": 0, 44 | "x": 873, 45 | "y": 144, 46 | "strokeColor": "#000000", 47 | "backgroundColor": "transparent", 48 | "width": 219, 49 | "height": 35, 50 | "seed": 22743313, 51 | "groupIds": [], 52 | "strokeSharpness": "sharp", 53 | "boundElementIds": [], 54 | "fontSize": 28, 55 | "fontFamily": 1, 56 | "text": "TermPair Server", 57 | "baseline": 25, 58 | "textAlign": "left", 59 | "verticalAlign": "top" 60 | }, 61 | { 62 | "type": "text", 63 | "version": 512, 64 | "versionNonce": 1696647945, 65 | "isDeleted": false, 66 | "id": "CFNLg6F_u_DL1oeCwtGcU", 67 | "fillStyle": "hachure", 68 | "strokeWidth": 1, 69 | "strokeStyle": "solid", 70 | "roughness": 1, 71 | "opacity": 100, 72 | "angle": 0, 73 | "x": 847, 74 | "y": 188, 75 | "strokeColor": "#000000", 76 | "backgroundColor": "transparent", 77 | "width": 284, 78 | "height": 225, 79 | "seed": 1114073055, 80 | "groupIds": [], 81 | "strokeSharpness": "sharp", 82 | "boundElementIds": [], 83 | "fontSize": 20, 84 | "fontFamily": 1, 85 | "text": "- acts as a router between\nUnix terminals and connected\nbrowsers\n- receives and sends only \nencrypted terminal data\n- never receives secret\nencryption keys in plaintext\n- serves webpage to view\nand control terminals", 86 | "baseline": 218, 87 | "textAlign": "left", 88 | "verticalAlign": "top" 89 | }, 90 | { 91 | "type": "rectangle", 92 | "version": 380, 93 | "versionNonce": 1958888778, 94 | "isDeleted": false, 95 | "id": "O48MgtwSVKsgdCfU6g8YH", 96 | "fillStyle": "hachure", 97 | "strokeWidth": 1, 98 | "strokeStyle": "solid", 99 | "roughness": 1, 100 | "opacity": 100, 101 | "angle": 0, 102 | "x": 1231, 103 | "y": 582, 104 | "strokeColor": "#000000", 105 | "backgroundColor": "transparent", 106 | "width": 563.0000000000001, 107 | "height": 337.99999999999994, 108 | "seed": 1030393489, 109 | "groupIds": [], 110 | "strokeSharpness": "sharp", 111 | "boundElementIds": [ 112 | "QDEk0R4uM0Jk487w7_U_C" 113 | ] 114 | }, 115 | { 116 | "type": "text", 117 | "version": 158, 118 | "versionNonce": 410241937, 119 | "isDeleted": false, 120 | "id": "f5afq5vkLy8uDHwVGa7pG", 121 | "fillStyle": "hachure", 122 | "strokeWidth": 1, 123 | "strokeStyle": "solid", 124 | "roughness": 1, 125 | "opacity": 100, 126 | "angle": 0, 127 | "x": 1265, 128 | "y": 610, 129 | "strokeColor": "#000000", 130 | "backgroundColor": "transparent", 131 | "width": 301, 132 | "height": 35, 133 | "seed": 1042414015, 134 | "groupIds": [], 135 | "strokeSharpness": "sharp", 136 | "boundElementIds": [], 137 | "fontSize": 28, 138 | "fontFamily": 1, 139 | "text": "Connected Browser(s)", 140 | "baseline": 25, 141 | "textAlign": "left", 142 | "verticalAlign": "top" 143 | }, 144 | { 145 | "type": "text", 146 | "version": 1073, 147 | "versionNonce": 672392458, 148 | "isDeleted": false, 149 | "id": "e-Wl_LzVogxM43annJJMP", 150 | "fillStyle": "hachure", 151 | "strokeWidth": 1, 152 | "strokeStyle": "solid", 153 | "roughness": 1, 154 | "opacity": 100, 155 | "angle": 0, 156 | "x": 1265, 157 | "y": 660.5, 158 | "strokeColor": "#000000", 159 | "backgroundColor": "transparent", 160 | "width": 477, 161 | "height": 175, 162 | "seed": 676973041, 163 | "groupIds": [], 164 | "strokeSharpness": "sharp", 165 | "boundElementIds": [], 166 | "fontSize": 20, 167 | "fontFamily": 1, 168 | "text": "- Securely receives encryption keys from terminal\n- Encrypted terminal data is received\nfrom server, decrypted, and rendered\n- User input to browser terminal is\nencrypted with a different key, then sent \nto the TermPair server, which sends to the \nbroadcasting terminal", 169 | "baseline": 168, 170 | "textAlign": "left", 171 | "verticalAlign": "top" 172 | }, 173 | { 174 | "type": "rectangle", 175 | "version": 327, 176 | "versionNonce": 856022346, 177 | "isDeleted": false, 178 | "id": "OWQ2EsCrfdkPBdrX6R0Gq", 179 | "fillStyle": "hachure", 180 | "strokeWidth": 1, 181 | "strokeStyle": "solid", 182 | "roughness": 1, 183 | "opacity": 100, 184 | "angle": 0, 185 | "x": 243, 186 | "y": 579, 187 | "strokeColor": "#000000", 188 | "backgroundColor": "transparent", 189 | "width": 655, 190 | "height": 363, 191 | "seed": 1183293969, 192 | "groupIds": [], 193 | "strokeSharpness": "sharp", 194 | "boundElementIds": [] 195 | }, 196 | { 197 | "type": "text", 198 | "version": 111, 199 | "versionNonce": 596807633, 200 | "isDeleted": false, 201 | "id": "sHuS9-RvwSMpHOg-XhhSh", 202 | "fillStyle": "hachure", 203 | "strokeWidth": 1, 204 | "strokeStyle": "solid", 205 | "roughness": 1, 206 | "opacity": 100, 207 | "angle": 0, 208 | "x": 364, 209 | "y": 596, 210 | "strokeColor": "#000000", 211 | "backgroundColor": "transparent", 212 | "width": 297, 213 | "height": 70, 214 | "seed": 551460255, 215 | "groupIds": [], 216 | "strokeSharpness": "sharp", 217 | "boundElementIds": [], 218 | "fontSize": 28, 219 | "fontFamily": 1, 220 | "text": "Unix Terminal Running \nTermPair Client", 221 | "baseline": 60, 222 | "textAlign": "center", 223 | "verticalAlign": "top" 224 | }, 225 | { 226 | "type": "text", 227 | "version": 782, 228 | "versionNonce": 248085578, 229 | "isDeleted": false, 230 | "id": "C7kYz0sw95i80gC6HcfOG", 231 | "fillStyle": "hachure", 232 | "strokeWidth": 1, 233 | "strokeStyle": "solid", 234 | "roughness": 1, 235 | "opacity": 100, 236 | "angle": 0, 237 | "x": 268.5, 238 | "y": 677.5, 239 | "strokeColor": "#000000", 240 | "backgroundColor": "transparent", 241 | "width": 446, 242 | "height": 200, 243 | "seed": 2133495281, 244 | "groupIds": [], 245 | "strokeSharpness": "sharp", 246 | "boundElementIds": [], 247 | "fontSize": 20, 248 | "fontFamily": 1, 249 | "text": "- client session is registered with TermPair\nserver with unique terminal ID\n- pty process is started with new shell\n- secret encryption keys are created\n- all pty IO is encrypted, then sent to\nTermPair server for further routing\n- this client can also receive input from the \nbrowser via the server", 250 | "baseline": 193, 251 | "textAlign": "left", 252 | "verticalAlign": "top" 253 | }, 254 | { 255 | "type": "arrow", 256 | "version": 43, 257 | "versionNonce": 592004255, 258 | "isDeleted": false, 259 | "id": "dVA9y0k0LFcHXoMw80ojq", 260 | "fillStyle": "hachure", 261 | "strokeWidth": 1, 262 | "strokeStyle": "solid", 263 | "roughness": 1, 264 | "opacity": 100, 265 | "angle": 0, 266 | "x": 456, 267 | "y": 514, 268 | "strokeColor": "#000000", 269 | "backgroundColor": "transparent", 270 | "width": 286, 271 | "height": 215, 272 | "seed": 1120797183, 273 | "groupIds": [], 274 | "strokeSharpness": "round", 275 | "boundElementIds": [], 276 | "startBinding": null, 277 | "endBinding": null, 278 | "lastCommittedPoint": null, 279 | "startArrowhead": null, 280 | "endArrowhead": "arrow", 281 | "points": [ 282 | [ 283 | 0, 284 | 0 285 | ], 286 | [ 287 | 286, 288 | -215 289 | ] 290 | ] 291 | }, 292 | { 293 | "type": "arrow", 294 | "version": 49, 295 | "versionNonce": 2091726289, 296 | "isDeleted": false, 297 | "id": "EXfCi8zEsjJwIBcCbMTAU", 298 | "fillStyle": "hachure", 299 | "strokeWidth": 1, 300 | "strokeStyle": "solid", 301 | "roughness": 1, 302 | "opacity": 100, 303 | "angle": 0, 304 | "x": 741, 305 | "y": 353, 306 | "strokeColor": "#000000", 307 | "backgroundColor": "transparent", 308 | "width": 248, 309 | "height": 179, 310 | "seed": 72489169, 311 | "groupIds": [], 312 | "strokeSharpness": "round", 313 | "boundElementIds": [], 314 | "startBinding": null, 315 | "endBinding": null, 316 | "lastCommittedPoint": null, 317 | "startArrowhead": null, 318 | "endArrowhead": "arrow", 319 | "points": [ 320 | [ 321 | 0, 322 | 0 323 | ], 324 | [ 325 | -248, 326 | 179 327 | ] 328 | ] 329 | }, 330 | { 331 | "type": "text", 332 | "version": 71, 333 | "versionNonce": 722171967, 334 | "isDeleted": false, 335 | "id": "MswhbZShtB1CAmwOrRiHz", 336 | "fillStyle": "hachure", 337 | "strokeWidth": 1, 338 | "strokeStyle": "solid", 339 | "roughness": 1, 340 | "opacity": 100, 341 | "angle": 0, 342 | "x": 459, 343 | "y": 338, 344 | "strokeColor": "#000000", 345 | "backgroundColor": "transparent", 346 | "width": 190, 347 | "height": 40, 348 | "seed": 1481018303, 349 | "groupIds": [], 350 | "strokeSharpness": "sharp", 351 | "boundElementIds": [], 352 | "fontSize": 16, 353 | "fontFamily": 1, 354 | "text": "encrypted terminal data\nsent via websocket", 355 | "baseline": 34, 356 | "textAlign": "left", 357 | "verticalAlign": "top" 358 | }, 359 | { 360 | "type": "arrow", 361 | "version": 230, 362 | "versionNonce": 1091762710, 363 | "isDeleted": false, 364 | "id": "xtf852WRDZbeXkDCLUj44", 365 | "fillStyle": "hachure", 366 | "strokeWidth": 1, 367 | "strokeStyle": "solid", 368 | "roughness": 1, 369 | "opacity": 100, 370 | "angle": 1.3309096658263648, 371 | "x": 1210.3880694655807, 372 | "y": 559.7347250217291, 373 | "strokeColor": "#000000", 374 | "backgroundColor": "transparent", 375 | "width": 290.296200973173, 376 | "height": 252.1143679070526, 377 | "seed": 661853119, 378 | "groupIds": [], 379 | "strokeSharpness": "round", 380 | "boundElementIds": [], 381 | "startBinding": { 382 | "elementId": "IALiHKOmluotiryCW6iS_", 383 | "focus": -0.48141139032374475, 384 | "gap": 12.216105784663256 385 | }, 386 | "endBinding": null, 387 | "lastCommittedPoint": null, 388 | "startArrowhead": null, 389 | "endArrowhead": "arrow", 390 | "points": [ 391 | [ 392 | 0, 393 | 0 394 | ], 395 | [ 396 | 290.296200973173, 397 | -252.1143679070526 398 | ] 399 | ] 400 | }, 401 | { 402 | "type": "arrow", 403 | "version": 620, 404 | "versionNonce": 339173142, 405 | "isDeleted": false, 406 | "id": "QDEk0R4uM0Jk487w7_U_C", 407 | "fillStyle": "hachure", 408 | "strokeWidth": 1, 409 | "strokeStyle": "solid", 410 | "roughness": 1, 411 | "opacity": 100, 412 | "angle": 1.2975769160479205, 413 | "x": 1477.3256899469802, 414 | "y": 353.90640202219834, 415 | "strokeColor": "#000000", 416 | "backgroundColor": "transparent", 417 | "width": 269.6174213993611, 418 | "height": 216.92233825210434, 419 | "seed": 197675953, 420 | "groupIds": [], 421 | "strokeSharpness": "round", 422 | "boundElementIds": [], 423 | "startBinding": { 424 | "elementId": "O48MgtwSVKsgdCfU6g8YH", 425 | "gap": 19.209072719427365, 426 | "focus": 0.4505434760362603 427 | }, 428 | "endBinding": { 429 | "elementId": "IALiHKOmluotiryCW6iS_", 430 | "gap": 24.37702657973448, 431 | "focus": -0.3357611923207028 432 | }, 433 | "lastCommittedPoint": null, 434 | "startArrowhead": null, 435 | "endArrowhead": "arrow", 436 | "points": [ 437 | [ 438 | 0, 439 | 0 440 | ], 441 | [ 442 | -269.6174213993611, 443 | 216.92233825210434 444 | ] 445 | ] 446 | }, 447 | { 448 | "type": "text", 449 | "version": 180, 450 | "versionNonce": 1065622783, 451 | "isDeleted": false, 452 | "id": "TMfsRd32Fdtjdlbn-Y-EK", 453 | "fillStyle": "hachure", 454 | "strokeWidth": 1, 455 | "strokeStyle": "solid", 456 | "roughness": 1, 457 | "opacity": 100, 458 | "angle": 0, 459 | "x": 1311, 460 | "y": 344.5, 461 | "strokeColor": "#000000", 462 | "backgroundColor": "transparent", 463 | "width": 190, 464 | "height": 40, 465 | "seed": 746079199, 466 | "groupIds": [], 467 | "strokeSharpness": "sharp", 468 | "boundElementIds": [], 469 | "fontSize": 16, 470 | "fontFamily": 1, 471 | "text": "encrypted terminal data\nsent via websocket", 472 | "baseline": 34, 473 | "textAlign": "left", 474 | "verticalAlign": "top" 475 | }, 476 | { 477 | "type": "text", 478 | "version": 3, 479 | "versionNonce": 1276801802, 480 | "isDeleted": false, 481 | "id": "oVD66yq7ru63NlcaOqIO4", 482 | "fillStyle": "hachure", 483 | "strokeWidth": 1, 484 | "strokeStyle": "solid", 485 | "roughness": 1, 486 | "opacity": 100, 487 | "angle": 0, 488 | "x": 299.3474202156067, 489 | "y": 101.18263411521912, 490 | "strokeColor": "#000000", 491 | "backgroundColor": "transparent", 492 | "width": 251, 493 | "height": 20, 494 | "seed": 1516145738, 495 | "groupIds": [], 496 | "strokeSharpness": "sharp", 497 | "boundElementIds": [], 498 | "fontSize": 16, 499 | "fontFamily": 1, 500 | "text": "https://github.com/cs01/termpair", 501 | "baseline": 14, 502 | "textAlign": "left", 503 | "verticalAlign": "top" 504 | } 505 | ], 506 | "appState": { 507 | "gridSize": null, 508 | "viewBackgroundColor": "#ffffff" 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /docs/termpair_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cs01/termpair/543d84af21e2892a8b6f177d1c397b88973b0e98/docs/termpair_architecture.png -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean build publish docs install_frontend 2 | 3 | install_frontend: 4 | cd termpair/frontend_src && yarn install 5 | 6 | watch_frontend: install_frontend 7 | cd termpair/frontend_src && yarn start 8 | 9 | build_frontend: install_frontend 10 | cd termpair/frontend_src && yarn build 11 | 12 | clean: 13 | rm -r build dist *.egg-info || true 14 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: TermPair 2 | site_description: View and control remote terminals from your browser 3 | 4 | theme: 5 | name: "material" 6 | 7 | repo_name: cs01/TermPair 8 | repo_url: https://github.com/cs01/termpair 9 | 10 | nav: 11 | - Overview: "index.md" 12 | - Contributing: "contributing.md" 13 | - Changelog: "changelog.md" 14 | 15 | markdown_extensions: 16 | - markdown.extensions.codehilite: 17 | guess_lang: false 18 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nox # type: ignore 4 | 5 | python = ["3.10"] 6 | nox.options.sessions = ["lint"] 7 | nox.options.reuse_existing_virtualenvs = True 8 | 9 | doc_deps = [".", "jinja2", "mkdocs", "mkdocs-material"] 10 | dev_deps = ["mypy", "black"] 11 | lint_deps = ["black", "flake8", "flake8-bugbear", "mypy", "check-manifest"] 12 | test_deps = [ 13 | "pytest", 14 | # required by FastAPI's test code 15 | "requests", 16 | "psutil", 17 | ] 18 | 19 | 20 | @nox.session(python=python) 21 | def serve(session): 22 | """Install and run termpair serve """ 23 | print("Note: Frontend must be built for this to work") 24 | session.install("-e", ".") 25 | session.run("termpair", "serve", *session.posargs) 26 | 27 | 28 | @nox.session(python=python) 29 | def share(session): 30 | """Install and run 'termpair share '""" 31 | print("Note: Frontend must be built for this to work") 32 | session.install("-e", ".") 33 | session.run("termpair", "share", *session.posargs) 34 | 35 | 36 | @nox.session(python=python) 37 | def watch_docs(session): 38 | """Build mkdocs, run server, and watch for changes""" 39 | session.install(*doc_deps) 40 | session.run("mkdocs", "serve") 41 | 42 | 43 | @nox.session(python=python) 44 | def build_frontend(session): 45 | session.run("yarn", "--cwd", "termpair/frontend_src", "install", external=True) 46 | session.run("yarn", "--cwd", "termpair/frontend_src", "build", external=True) 47 | 48 | 49 | @nox.session(python=python) 50 | def build_executable(session): 51 | """Builds a pex of termpair""" 52 | session.install("pex==2.1.93") 53 | session.run( 54 | "pex", 55 | ".", 56 | "--console-script", 57 | "termpair", 58 | "--output-file", 59 | "build/termpair.pex", 60 | "--sh-boot", 61 | "--validate-entry-point", 62 | external=True, 63 | ) 64 | 65 | 66 | @nox.session() 67 | def publish_docs(session): 68 | """Run mkdocs gh-deploy""" 69 | session.install(*doc_deps) 70 | session.run("mkdocs", "gh-deploy") 71 | 72 | 73 | @nox.session() 74 | def publish_static_webapp(session): 75 | """Build frontend and publish to github pages""" 76 | build_frontend(session) 77 | session.run("git", "checkout", "gh-pages", external=True) 78 | session.run("rm", "-rf", "connect/", external=True) 79 | session.run("mkdir", "connect", external=True) 80 | session.run("cp", "-rT", "termpair/frontend_build/", "connect/", external=True) 81 | session.run("git", "add", "connect", external=True) 82 | session.run("git", "commit", "-m", "commit built frontend", external=True) 83 | session.run("git", "push", "origin", "gh-pages", external=True) 84 | 85 | 86 | @nox.session() 87 | def publish(session): 88 | """Build+Publish to PyPI, docs, and static webapp""" 89 | print("REMINDER: Has the changelog been updated?") 90 | session.run("rm", "-rf", "dist", "build", external=True) 91 | publish_deps = ["setuptools", "wheel", "twine"] 92 | session.install(*publish_deps) 93 | session.run("make", "build_frontend", external=True) 94 | session.run("python", "setup.py", "--quiet", "sdist", "bdist_wheel") 95 | session.run("python", "-m", "twine", "upload", "dist/*") 96 | publish_docs(session) 97 | publish_static_webapp(session) 98 | 99 | 100 | @nox.session(python=python) 101 | def lint(session): 102 | """Run all lint checks""" 103 | session.install(*lint_deps) 104 | files = ["termpair", "tests"] + [str(p) for p in Path(".").glob("*.py")] 105 | session.run("black", "--check", *files) 106 | session.run("flake8", *files) 107 | session.run("mypy", *files) 108 | session.run("check-manifest") 109 | session.run("python", "setup.py", "check", "--metadata", "--strict") 110 | 111 | 112 | @nox.session(python=[python]) 113 | def test(session): 114 | """Run unit tests""" 115 | session.install(".", *test_deps) 116 | # can't use default capture method because termpair requires stdin to have a fileno() 117 | session.run("pytest", "tests", "--capture", "tee-sys", *session.posargs) 118 | 119 | 120 | @nox.session(python=[python]) 121 | def termpair(session): 122 | """Install termapir and run it with args passed with -- arg1 arg2""" 123 | session.install("-e", ".") 124 | session.run("termpair", *session.posargs) 125 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn 3 | uvicorn[standard] 4 | aiofiles 5 | cryptography 6 | websockets 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.9 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | aiofiles==0.8.0 8 | # via -r requirements.in 9 | anyio==3.6.1 10 | # via 11 | # starlette 12 | # watchgod 13 | asgiref==3.5.2 14 | # via uvicorn 15 | cffi==1.15.0 16 | # via cryptography 17 | click==8.1.3 18 | # via uvicorn 19 | cryptography==37.0.3 20 | # via -r requirements.in 21 | fastapi==0.78.0 22 | # via -r requirements.in 23 | h11==0.13.0 24 | # via uvicorn 25 | httptools==0.4.0 26 | # via uvicorn 27 | idna==3.3 28 | # via anyio 29 | pycparser==2.21 30 | # via cffi 31 | pydantic==1.9.1 32 | # via fastapi 33 | python-dotenv==0.20.0 34 | # via uvicorn 35 | pyyaml==6.0 36 | # via uvicorn 37 | sniffio==1.2.0 38 | # via anyio 39 | starlette==0.19.1 40 | # via fastapi 41 | typing-extensions==4.2.0 42 | # via 43 | # pydantic 44 | # starlette 45 | uvicorn[standard]==0.17.6 46 | # via -r requirements.in 47 | uvloop==0.16.0 48 | # via uvicorn 49 | watchgod==0.8.2 50 | # via uvicorn 51 | websockets==10.3 52 | # via 53 | # -r requirements.in 54 | # uvicorn 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import ast 4 | import distutils.text_file 5 | import io 6 | import os 7 | import re 8 | 9 | from setuptools import find_packages, setup # type: ignore 10 | 11 | EXCLUDE_FROM_PACKAGES = ["contrib", "docs", "tests*"] 12 | CURDIR = os.path.abspath(os.path.dirname(__file__)) 13 | 14 | with io.open(os.path.join(CURDIR, "README.md"), "r", encoding="utf-8") as f: 15 | README = f.read() 16 | 17 | 18 | def get_version(): 19 | main_file = os.path.join(CURDIR, "termpair", "constants.py") 20 | _version_re = re.compile(r"TERMPAIR_VERSION\s+=\s+(?P.*)") 21 | with open(main_file, "r", encoding="utf8") as f: 22 | match = _version_re.search(f.read()) 23 | version = match.group("version") if match is not None else '"unknown"' 24 | return str(ast.literal_eval(version)) 25 | 26 | 27 | setup( 28 | name="termpair", 29 | version=get_version(), 30 | author="Chad Smith", 31 | author_email="chadsmith.software@gmail.com", 32 | description="View and control remote terminals from your browser with end-to-end encryption", 33 | long_description=README, 34 | long_description_content_type="text/markdown", 35 | url="https://github.com/cs01/termpair", 36 | packages=find_packages(exclude=EXCLUDE_FROM_PACKAGES), 37 | include_package_data=True, 38 | keywords=["e2ee", "secure", "terminal", "share", "broadcast", "pty", "websockets"], 39 | scripts=[], 40 | entry_points={"console_scripts": ["termpair=termpair.main:main"]}, 41 | zip_safe=False, 42 | install_requires=distutils.text_file.TextFile( 43 | filename="./requirements.txt" 44 | ).readlines(), 45 | python_requires=">=3.8", 46 | # license and classifier list: 47 | # https://pypi.org/pypi?%3Aaction=list_classifiers 48 | license="License :: OSI Approved :: MIT License", 49 | classifiers=[ 50 | "Programming Language :: Python :: 3", 51 | "Operating System :: MacOS", 52 | "Operating System :: POSIX :: Linux", 53 | "Development Status :: 4 - Beta", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /termpair/Terminal.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import List, NamedTuple, NewType 3 | 4 | from starlette.websockets import WebSocket # type: ignore 5 | 6 | 7 | class Terminal(NamedTuple): 8 | ws: WebSocket 9 | rows: int 10 | cols: int 11 | browser_websockets: List[WebSocket] 12 | browser_tasks: List[asyncio.Task] 13 | allow_browser_control: bool 14 | command: str 15 | broadcast_start_time_iso: str 16 | subprotocol_version: str 17 | 18 | 19 | TerminalId = NewType("TerminalId", str) 20 | -------------------------------------------------------------------------------- /termpair/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cs01/termpair/543d84af21e2892a8b6f177d1c397b88973b0e98/termpair/__init__.py -------------------------------------------------------------------------------- /termpair/constants.py: -------------------------------------------------------------------------------- 1 | subprotocol_version = "3" 2 | 3 | 4 | class TermPairError(Exception): 5 | pass 6 | 7 | 8 | # this must match constants.tsx 9 | TERMPAIR_VERSION = "0.3.1.5" 10 | -------------------------------------------------------------------------------- /termpair/encryption.py: -------------------------------------------------------------------------------- 1 | """ 2 | Symmetric encryption with aes gcm 3 | https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM 4 | """ 5 | 6 | from cryptography.hazmat.primitives import hashes # type:ignore 7 | from cryptography.hazmat.primitives.asymmetric import padding, rsa # type:ignore 8 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM # type: ignore 9 | from cryptography.hazmat.primitives.serialization import ( # type:ignore 10 | load_pem_public_key, 11 | ) 12 | 13 | IV_LENGTH = 12 14 | KEY_LENGTH_BITS = 128 15 | 16 | 17 | def import_rsa_key(pem_public_key: str): 18 | pem_bytes = pem_public_key.encode() 19 | return load_pem_public_key(pem_bytes) 20 | 21 | 22 | def rsa_encrypt(public_key: rsa.RSAPublicKey, message: bytes) -> bytes: 23 | return public_key.encrypt( 24 | message, 25 | padding.OAEP( 26 | mgf=padding.MGF1(algorithm=hashes.SHA256()), 27 | algorithm=hashes.SHA256(), 28 | label=None, 29 | ), 30 | ) 31 | 32 | 33 | def aes_generate_secret_key() -> bytes: 34 | return AESGCM.generate_key(bit_length=KEY_LENGTH_BITS) 35 | 36 | 37 | def aes_encrypt(message_count: int, key: bytes, data: bytes) -> bytes: 38 | # the same iv must never be reused with a given key 39 | iv = message_count.to_bytes(IV_LENGTH, "little") 40 | # prepend unencrypted iv to the encrypted payload 41 | return iv + AESGCM(key).encrypt(iv, data, None) 42 | 43 | 44 | def aes_decrypt(key: bytes, data: bytes) -> str: 45 | # unencrypted iv must be prepended to the payload 46 | iv = data[0:IV_LENGTH] 47 | return AESGCM(key).decrypt(iv, data[IV_LENGTH:], None).decode() 48 | -------------------------------------------------------------------------------- /termpair/frontend_src/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /termpair/frontend_src/craco.config.js: -------------------------------------------------------------------------------- 1 | // craco.config.js 2 | module.exports = { 3 | style: { 4 | postcss: { 5 | plugins: [require("tailwindcss"), require("autoprefixer")], 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /termpair/frontend_src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "termpairjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.2.0", 7 | "@heroicons/react": "^1.0.2", 8 | "@types/jest": "^26.0.24", 9 | "@types/node": "^16.3.1", 10 | "@types/react": "^17.0.14", 11 | "@types/react-dom": "^17.0.9", 12 | "autoprefixer": "^9", 13 | "debounce": "^1.2.1", 14 | "moment": "^2.24.0", 15 | "postcss": "^7", 16 | "react": "^16.8.4", 17 | "react-copy-to-clipboard": "^5.0.3", 18 | "react-dom": "^16.8.4", 19 | "react-scripts": "^4.0.3", 20 | "react-toastify": "^7.0.4", 21 | "tailwindcss": "npm:@tailwindcss/postcss7-compat", 22 | "typescript": "^4.3.5", 23 | "xterm": "^4.4.0" 24 | }, 25 | "scripts": { 26 | "start": "craco start", 27 | "build": "craco build", 28 | "postbuild": "rm -r ../frontend_build; mv build ../frontend_build", 29 | "test": "craco test", 30 | "eject": "craco eject" 31 | }, 32 | "homepage": ".", 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": [ 37 | ">0.2%", 38 | "not dead", 39 | "not ie <= 11", 40 | "not op_mini all" 41 | ], 42 | "devDependencies": { 43 | "@tailwindcss/aspect-ratio": "^0.2.1", 44 | "@types/debounce": "^1.2.0", 45 | "@types/react-copy-to-clipboard": "^5.0.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /termpair/frontend_src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cs01/termpair/543d84af21e2892a8b6f177d1c397b88973b0e98/termpair/frontend_src/public/favicon.ico -------------------------------------------------------------------------------- /termpair/frontend_src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 14 | 15 | TermPair: View and control remote terminals from your browser with 16 | end-to-end encryption 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /termpair/frontend_src/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef, useLayoutEffect } from "react"; 2 | import "xterm/css/xterm.css"; 3 | import { Terminal as Xterm, IDisposable } from "xterm"; 4 | import { getBootstrapAESKey } from "./encryption"; 5 | import { ToastContainer, toast } from "react-toastify"; 6 | import "react-toastify/dist/ReactToastify.css"; 7 | import { newBrowserConnected, requestTerminalDimensions } from "./events"; 8 | import { LandingPageContent } from "./LandingPageContent"; 9 | import { AesKeysRef, Status, TerminalServerData, TerminalSize } from "./types"; 10 | import { TopBar } from "./TopBar"; 11 | import { ErrorBoundary } from "./ErrorBoundary"; 12 | import { BottomBar } from "./BottomBar"; 13 | import { 14 | defaultTerminalId, 15 | defaultTermpairServer, 16 | secureContextHelp, 17 | xterm, 18 | } from "./constants"; 19 | import { toastStatus, websocketUrlFromHttpUrl } from "./utils"; 20 | import { 21 | getCustomKeyEventHandler, 22 | getOnDataHandler, 23 | redXtermText, 24 | } from "./xtermUtils"; 25 | import { handlers, TermPairEvent } from "./websocketMessageHandler"; 26 | 27 | function handleStatusChange( 28 | status: Status, 29 | prevStatus: Status, 30 | setPrevStatus: (prevStatus: Status) => void 31 | ): void { 32 | setPrevStatus(status); 33 | switch (status) { 34 | case null: 35 | break; 36 | case "Connection Established": 37 | toastStatus(status); 38 | xterm.writeln("Connection established with end-to-end encryption 🔒."); 39 | xterm.writeln( 40 | "The termpair server and third parties can't read transmitted data." 41 | ); 42 | xterm.writeln(""); 43 | xterm.writeln( 44 | "You can copy text with ctrl+shift+c or ctrl+shift+x, and paste with ctrl+shift+v." 45 | ); 46 | xterm.writeln(""); 47 | break; 48 | case "Disconnected": 49 | toastStatus(status); 50 | if (prevStatus === "Connection Established") { 51 | xterm.writeln(redXtermText("Terminal session has ended")); 52 | xterm.writeln(""); 53 | } 54 | break; 55 | case "Terminal ID is invalid": 56 | toast.dark( 57 | `An invalid Terminal ID was provided. ` + 58 | `Check that the session is still being broadcast and that the ID is entered correctly.` 59 | ); 60 | break; 61 | 62 | case "Failed to obtain encryption keys": 63 | toast.dark( 64 | `Failed to obtain secret encryption keys from the broadcasting terminal. ` + 65 | `Is your encryption key valid?` 66 | ); 67 | break; 68 | 69 | case "Browser is not running in a secure context": 70 | toast.dark(secureContextHelp); 71 | break; 72 | 73 | case "Connecting...": 74 | break; 75 | 76 | case "Connection Error": 77 | break; 78 | 79 | case "Failed to fetch terminal data": 80 | break; 81 | 82 | default: 83 | ((_: "Unhandled switch case"): never => { 84 | throw Error; 85 | })(status); 86 | } 87 | return status as never; 88 | } 89 | 90 | function ensureXtermIsOpen( 91 | xtermWasOpened: React.MutableRefObject, 92 | xterm: Xterm 93 | ) { 94 | if (xtermWasOpened.current) { 95 | return; 96 | } 97 | const el = document.getElementById("terminal"); 98 | if (!el) { 99 | return; 100 | } 101 | xterm.open(el); 102 | xtermWasOpened.current = true; 103 | xterm.writeln(`Welcome to TermPair! https://github.com/cs01/termpair`); 104 | xterm.writeln(""); 105 | } 106 | 107 | function App() { 108 | const [isStaticallyHosted, setIsStaticallyHosted] = 109 | useState>(null); 110 | const [terminalServerData, setTerminalServerData] = 111 | useState>(null); 112 | const [numClients, setNumClients] = useState(0); 113 | 114 | const aesKeys = useRef({ 115 | browser: null, 116 | unix: null, 117 | ivCount: null, 118 | maxIvCount: null, 119 | }); 120 | const xtermWasOpened = useRef(false); 121 | const [webSocket, setWebsocket] = useState>(null); 122 | const showTerminal = webSocket !== null; 123 | const [terminalSize, setTerminalSize] = useState({ 124 | rows: 20, 125 | cols: 81, 126 | }); 127 | const [status, setStatus] = useState(null); 128 | const [prevStatus, setPrevStatus] = useState(null); 129 | const [terminalId, setTerminalId] = useState(defaultTerminalId); 130 | 131 | useEffect(() => { 132 | if (!window.isSecureContext) { 133 | changeStatus("Browser is not running in a secure context"); 134 | return; 135 | } 136 | // run once when initially opened 137 | const initialize = async () => { 138 | let staticallyHosted; 139 | try { 140 | const ret = await fetch(defaultTermpairServer.toString() + "ping", { 141 | mode: "same-origin", 142 | }); 143 | const text = await ret.json(); 144 | const pong = text === "pong"; 145 | const isTermpairServer = ret.status === 200 && pong; 146 | staticallyHosted = !isTermpairServer; 147 | setIsStaticallyHosted(staticallyHosted); 148 | } catch (e) { 149 | staticallyHosted = true; 150 | setIsStaticallyHosted(staticallyHosted); 151 | } 152 | const bootstrapKey = await getBootstrapAESKey(); 153 | 154 | const termpairServerUrlParam = new URLSearchParams( 155 | window.location.search 156 | ).get("termpair_server_url"); 157 | 158 | const customTermpairServer = termpairServerUrlParam 159 | ? new URL(termpairServerUrlParam) 160 | : null; 161 | 162 | const termpairHttpServer = staticallyHosted 163 | ? customTermpairServer 164 | : defaultTermpairServer; 165 | 166 | if (terminalId && termpairHttpServer && bootstrapKey) { 167 | const termpairWebsocketServer = 168 | websocketUrlFromHttpUrl(termpairHttpServer); 169 | 170 | await connectToTerminalAndWebsocket( 171 | terminalId, 172 | termpairWebsocketServer, 173 | termpairHttpServer, 174 | bootstrapKey 175 | ); 176 | } 177 | }; 178 | initialize(); 179 | // eslint-disable-next-line react-hooks/exhaustive-deps 180 | }, []); 181 | 182 | useLayoutEffect(() => { 183 | ensureXtermIsOpen(xtermWasOpened, xterm); 184 | }, [showTerminal]); 185 | 186 | const changeStatus = (newStatus: Status) => { 187 | setStatus(newStatus); 188 | handleStatusChange(newStatus, prevStatus, setPrevStatus); 189 | }; 190 | 191 | async function connectToTerminalAndWebsocket( 192 | terminalId: string, 193 | termpairWebsocketServer: URL, 194 | termpairHttpServer: URL, 195 | bootstrapAesKey: CryptoKey 196 | ) { 197 | setTerminalId(terminalId); 198 | setTerminalServerData(null); 199 | try { 200 | const response = await fetch( 201 | new URL(`terminal/${terminalId}`, termpairHttpServer).toString() 202 | ); 203 | if (response.status === 200) { 204 | const data: TerminalServerData = await response.json(); 205 | setTerminalServerData(data); 206 | setupWebsocket( 207 | terminalId, 208 | data, 209 | termpairWebsocketServer, 210 | bootstrapAesKey 211 | ); 212 | } else { 213 | changeStatus("Terminal ID is invalid"); 214 | } 215 | } catch (e) { 216 | changeStatus(`Failed to fetch terminal data`); 217 | toast.dark( 218 | `Error fetching terminal data from ${termpairHttpServer.toString()}. Is the URL correct? Error message: ${String( 219 | e.message 220 | )}`, 221 | 222 | { autoClose: false } 223 | ); 224 | } 225 | } 226 | 227 | function setupWebsocket( 228 | terminalId: string, 229 | terminalServerData: TerminalServerData, 230 | termpairWebsocketServer: URL, 231 | bootstrapAesKey: CryptoKey 232 | ) { 233 | if (webSocket) { 234 | toast.dark("Closing existing connection"); 235 | webSocket.close(); 236 | } 237 | changeStatus("Connecting..."); 238 | const connectWebsocketUrl = new URL( 239 | `connect_browser_to_terminal?terminal_id=${terminalId}`, 240 | termpairWebsocketServer 241 | ); 242 | const ws = new WebSocket(connectWebsocketUrl.toString()); 243 | setWebsocket(ws); 244 | const handleNewInput = getOnDataHandler(ws, terminalServerData, aesKeys); 245 | xterm.attachCustomKeyEventHandler( 246 | getCustomKeyEventHandler( 247 | xterm, 248 | terminalServerData?.allow_browser_control, 249 | handleNewInput 250 | ) 251 | ); 252 | let onDataDispose: Nullable; 253 | ws.addEventListener("open", async (event) => { 254 | changeStatus("Connection Established"); 255 | ws.send(requestTerminalDimensions()); 256 | const newBrowserMessage = await newBrowserConnected(); 257 | ws.send(newBrowserMessage); 258 | onDataDispose = xterm.onData(handleNewInput); 259 | }); 260 | ws.addEventListener("close", (event) => { 261 | if (onDataDispose) { 262 | // stop trying to send data since the connection is closed 263 | onDataDispose.dispose(); 264 | } 265 | changeStatus("Disconnected"); 266 | setNumClients(0); 267 | }); 268 | 269 | ws.addEventListener("error", (event) => { 270 | if (onDataDispose) { 271 | // stop trying to send data since the connection is closed 272 | onDataDispose.dispose(); 273 | } 274 | 275 | console.error(event); 276 | toast.dark(`Websocket Connection Error: ${JSON.stringify(event)}`); 277 | changeStatus("Connection Error"); 278 | setNumClients(0); 279 | }); 280 | 281 | ws.addEventListener("message", async (message: { data: any }) => { 282 | let data: { event: TermPairEvent; [key: string]: any }; 283 | try { 284 | data = JSON.parse(message.data); 285 | } catch (e) { 286 | toast.dark("Failed to parse websocket message"); 287 | return; 288 | } 289 | 290 | switch (data.event) { 291 | case "new_output": 292 | return handlers.new_output(aesKeys, data); 293 | case "resize": 294 | return handlers.resize(data, setTerminalSize); 295 | case "num_clients": 296 | return handlers.num_clients(setNumClients, data); 297 | case "aes_keys": 298 | return handlers.aes_keys( 299 | aesKeys, 300 | bootstrapAesKey, 301 | data, 302 | changeStatus 303 | ); 304 | case "aes_key_rotation": 305 | return handlers.aes_key_rotation(aesKeys, data); 306 | case "error": 307 | return handlers.error(data); 308 | default: 309 | ((_: "Unhandled switch case"): never => { 310 | throw Error; 311 | })(data.event); 312 | return handlers.default(data); 313 | } 314 | }); 315 | } 316 | 317 | const content = ( 318 |
319 | {showTerminal ? ( 320 |
324 | ) : ( 325 | 330 | )} 331 |
332 | ); 333 | return ( 334 | 335 |
336 | 348 | 349 | {content} 350 | 357 |
358 |
359 | ); 360 | } 361 | 362 | export default App; 363 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import { Status, TerminalServerData, TerminalSize } from "./types"; 2 | import moment from "moment"; 3 | 4 | export function BottomBar(props: { 5 | status: Status; 6 | terminalData: Nullable; 7 | terminalId: Nullable; 8 | terminalSize: TerminalSize; 9 | numClients: number; 10 | }) { 11 | const connected = props.status === "Connection Established"; 12 | const hasTerminalId = props.terminalId != null; 13 | const status = hasTerminalId ?
{props.status}
: null; 14 | 15 | const canType = connected ? ( 16 |
21 | {props.terminalData?.allow_browser_control && connected 22 | ? "read/write" 23 | : "read only"} 24 |
25 | ) : null; 26 | 27 | const connectedClients = connected ? ( 28 |
29 | {props.numClients ? props.numClients : "0"} Connected Client(s) 30 |
31 | ) : null; 32 | 33 | const startTime = connected ? ( 34 |
35 | Started at{" "} 36 | {moment(props.terminalData?.broadcast_start_time_iso).format( 37 | "h:mm a on MMM Do, YYYY" 38 | )} 39 |
40 | ) : null; 41 | 42 | const terminalDimensions = connected ? ( 43 |
44 | {props.terminalSize.rows}x{props.terminalSize.cols} 45 |
46 | ) : null; 47 | 48 | return ( 49 |
50 | {hasTerminalId ? ( 51 |
56 | {status} 57 | {terminalDimensions} 58 | {canType} 59 | {connectedClients} 60 | {startTime} 61 |
62 | ) : null} 63 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/CopyCommand.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import CopyToClipboard from "react-copy-to-clipboard"; 3 | import { DuplicateIcon } from "@heroicons/react/solid"; 4 | 5 | export function CopyCommand(props: { command: string }) { 6 | const [clicked, setClicked] = useState(false); 7 | const [hovering, setHovering] = useState(false); 8 | return ( 9 |
10 | 15 | {props.command} 16 | 17 | 18 | 30 | 31 | {clicked ? "Copied!" : ""} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export class ErrorBoundary extends React.Component { 4 | constructor(props: any) { 5 | super(props); 6 | this.state = { hasError: false }; 7 | } 8 | 9 | static getDerivedStateFromError(error: any) { 10 | // Update state so the next render will show the fallback UI. 11 | return { hasError: true }; 12 | } 13 | 14 | componentDidCatch(error: any, errorInfo: any) { 15 | // You can also log the error to an error reporting service 16 | // logErrorToMyService(error, errorInfo); 17 | console.error(error); 18 | console.error(errorInfo); 19 | } 20 | 21 | render() { 22 | if (this.state.hasError) { 23 | // You can render any custom fallback UI 24 | return

Something went wrong.

; 25 | } 26 | 27 | return this.props.children; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/LandingPageContent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { toast } from "react-toastify"; 3 | import { 4 | defaultBootstrapb64Key, 5 | defaultTerminalId, 6 | defaultTermpairServer, 7 | localStorageKeys, 8 | pipxTermpairShareCommand, 9 | secureContextHelp, 10 | termpairShareCommand, 11 | TERMPAIR_VERSION, 12 | } from "./constants"; 13 | import { CopyCommand } from "./CopyCommand"; 14 | import { getAESKey } from "./encryption"; 15 | import { websocketUrlFromHttpUrl } from "./utils"; 16 | 17 | export function LandingPageContent(props: { 18 | isSecureContext: boolean; 19 | isStaticallyHosted: Nullable; 20 | connectToTerminalAndWebsocket: ( 21 | terminalId: string, 22 | termpairWebsocketServer: URL, 23 | termpairHttpServer: URL, 24 | bootstrapAesKey: CryptoKey 25 | ) => Promise; 26 | }) { 27 | const [terminalIdInput, setTerminalIdInput] = React.useState( 28 | defaultTerminalId ?? localStorage.getItem(localStorageKeys.terminalId) ?? "" 29 | ); 30 | const [customHostInput, setCustomHostInput] = React.useState( 31 | localStorage.getItem(localStorageKeys.host) ?? "" 32 | ); 33 | const [bootstrapAesKeyB64Input, setBootstrapAesKeyB64Input] = React.useState( 34 | (defaultBootstrapb64Key || 35 | localStorage.getItem(localStorageKeys.bootstrapAesKeyB64)) ?? 36 | "" 37 | ); 38 | 39 | const submitForm = async () => { 40 | if (!terminalIdInput) { 41 | toast.dark("Terminal ID cannot be empty"); 42 | return; 43 | } 44 | localStorage.setItem(localStorageKeys.terminalId, terminalIdInput); 45 | if (!bootstrapAesKeyB64Input) { 46 | toast.dark("Secret key cannot be empty"); 47 | return; 48 | } 49 | let termpairHttpServer: URL; 50 | if (props.isStaticallyHosted) { 51 | if (!customHostInput) { 52 | toast.dark("Host name cannot be empty"); 53 | return; 54 | } 55 | try { 56 | const customServer = new URL(customHostInput); 57 | termpairHttpServer = customServer; 58 | localStorage.setItem(localStorageKeys.host, customHostInput); 59 | } catch (e) { 60 | toast.dark(`${customHostInput} is not a valid url`); 61 | return; 62 | } 63 | } else { 64 | termpairHttpServer = defaultTermpairServer; 65 | } 66 | 67 | let bootstrapKey; 68 | try { 69 | bootstrapKey = await getAESKey( 70 | Buffer.from(bootstrapAesKeyB64Input, "base64"), 71 | ["decrypt"] 72 | ); 73 | localStorage.setItem( 74 | localStorageKeys.bootstrapAesKeyB64, 75 | bootstrapAesKeyB64Input 76 | ); 77 | } catch (e) { 78 | toast.dark(`Secret encryption key is not valid`); 79 | return; 80 | } 81 | 82 | const termpairWebsocketServer = websocketUrlFromHttpUrl(termpairHttpServer); 83 | 84 | await props.connectToTerminalAndWebsocket( 85 | terminalIdInput, 86 | termpairWebsocketServer, 87 | termpairHttpServer, 88 | bootstrapKey 89 | ); 90 | }; 91 | const inputClass = "text-black px-2 py-3 m-2 w-full font-mono"; 92 | 93 | const terminalIdInputEl = ( 94 |
98 | Terminal ID 99 | { 104 | setTerminalIdInput(event.target.value); 105 | }} 106 | value={terminalIdInput} 107 | placeholder="abcdef123456789abcded123456789" 108 | /> 109 |
110 | ); 111 | const bootstrapCryptoKeyInputEl = ( 112 |
113 | 114 | Secret encryption key 115 | 116 | { 122 | setBootstrapAesKeyB64Input(event.target.value); 123 | }} 124 | value={bootstrapAesKeyB64Input} 125 | /> 126 |
127 | ); 128 | const terminalServerUrlEl = ( 129 |
133 | 134 | TermPair Server URL 135 | 136 | { 142 | setCustomHostInput(event.target.value); 143 | }} 144 | value={customHostInput} 145 | /> 146 |
147 | ); 148 | 149 | const canConnect = 150 | terminalIdInput.length !== 0 && 151 | bootstrapAesKeyB64Input.length > 0 && 152 | props.isStaticallyHosted 153 | ? customHostInput.length !== 0 154 | : true; 155 | 156 | const connectButton = ( 157 |
158 | 167 |
168 | ); 169 | const connectForm = ( 170 |
{ 172 | e.preventDefault(); 173 | submitForm(); 174 | }} 175 | > 176 | {terminalIdInputEl} 177 | {bootstrapCryptoKeyInputEl} 178 | {props.isStaticallyHosted ? terminalServerUrlEl : null} 179 | {connectButton} 180 |
181 | ); 182 | const staticLandingContent = props.isSecureContext ? ( 183 |
184 |
This page is statically hosted
185 |
186 | This is a static page serving the TermPair JavaScript app. It is 187 | optional to use a statically served TermPair webapp, but it facilitates 188 | easily building and self-serving to be certain the JavaScript app has 189 | not been tampered with by an untrusted server. 190 |
191 |
192 | Connect to a broadcasting terminal by entering the fields below and 193 | clicking Connect. 194 |
195 | {connectForm} 196 |
197 | ) : null; 198 | 199 | const regularServerContent = ( 200 | <> 201 |
202 |
Quick Start
203 |
204 | If you have TermPair installed, share a terminal with this host: 205 |
206 | 207 |
Or if you have pipx, you can run TermPair via pipx:
208 | 209 |
210 |
211 |
Install TermPair
212 |
Install with pipx
213 | 214 |
Or install with pip
215 | 216 |
217 |
218 |
Connecting to a Terminal?
219 | If a terminal is already broadcasting and you'd like to connect to it, 220 | you don't need to install or run anything. Just fill out the form below 221 | and click Connect. 222 | {connectForm} 223 |
224 | 225 | ); 226 | 227 | const termpairDemoContent = ( 228 |
229 |
TermPair Demo
230 | {/* https://www.themes.dev/blog/easily-embed-responsive-youtube-video-with-tailwind-css/ */} 231 |
232 | 239 |
240 |
241 | ); 242 | 243 | return ( 244 |
245 |
246 |
247 |
Welcome to TermPair!
248 | Easily share terminals with end-to-end encryption 🔒. Terminal data is 249 | always encrypted before being routed through the server.{" "} 250 | Learn more. 251 |
252 | {!props.isSecureContext ? ( 253 |
254 |

Error

255 | {secureContextHelp} 256 |
257 | ) : null} 258 | {props.isStaticallyHosted === null 259 | ? null 260 | : props.isStaticallyHosted === true 261 | ? staticLandingContent 262 | : regularServerContent} 263 | 264 |
265 |
Troubleshooting
266 |
267 |
268 |
269 | Initial connection fails or is rejected 270 |
271 |
272 | Ensure you are using a TermPair client compatible with{" "} 273 | v{TERMPAIR_VERSION} (the 274 | version of this webpage) 275 |
276 |
277 |
278 |
279 | Browser is not running in a secure context 280 |
281 |
{secureContextHelp}
282 |
283 |
284 |
285 | {termpairDemoContent} 286 |
287 |
288 | ); 289 | } 290 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { TERMPAIR_VERSION } from "./constants"; 2 | import logo from "./logo.png"; // logomakr.com/4N54oK 3 | 4 | const githubLogo = ( 5 | 11 | 16 | 17 | ); 18 | 19 | export function TopBar(props: any) { 20 | return ( 21 |
22 |
23 |
24 | 25 | logo 26 | 27 |
28 |
29 | v{TERMPAIR_VERSION} 30 | 31 | {githubLogo} 32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/constants.tsx: -------------------------------------------------------------------------------- 1 | import { Terminal as Xterm } from "xterm"; 2 | // this must match constants.py 3 | export const TERMPAIR_VERSION = "0.3.1.5"; 4 | 5 | export const defaultTermpairServer = new URL( 6 | `${window.location.protocol}//${window.location.hostname}:${window.location.port}${window.location.pathname}` 7 | ); 8 | 9 | export const defaultTerminalId = new URLSearchParams( 10 | window.location.search 11 | ).get("terminal_id"); 12 | 13 | export const defaultBootstrapb64Key = window.location.hash.substring( 14 | 1, // skip the '#' symbol 15 | window.location.hash.length - 1 16 | ); 17 | 18 | export const cannotTypeMsg = 19 | "Terminal was shared in read only mode. Unable to send data to terminal's input."; 20 | 21 | export const host = `${window.location.protocol}//${window.location.hostname}${window.location.pathname}`; 22 | let _port = window.location.port; 23 | if (!window.location.port) { 24 | if (window.location.protocol === "https:") { 25 | _port = "443"; 26 | } else { 27 | _port = "80"; 28 | } 29 | } 30 | export const port = _port; 31 | export const termpairShareCommand = `termpair share --host "${host}" --port ${_port}`; 32 | export const pipxTermpairShareCommand = `pipx run ${termpairShareCommand}`; 33 | 34 | export const xterm = new Xterm({ 35 | cursorBlink: true, 36 | macOptionIsMeta: true, 37 | scrollback: 1000, 38 | }); 39 | 40 | export const localStorageKeys = { 41 | bootstrapAesKeyB64: "termpairBase64BootstrapKey", 42 | terminalId: "termpairTerminalId", 43 | host: "termpairCustomHost", 44 | }; 45 | 46 | export const secureContextHelp = ( 47 |
48 | TermPair only works on secure connections. The server must be configured to 49 | serve this page over https. See termpair serve --help and{" "} 50 | 51 | https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts 52 | {" "} 53 | for more information. 54 |
55 | ); 56 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/encryption.tsx: -------------------------------------------------------------------------------- 1 | // Symmetric encryption with aes gcm 2 | // https://github.com/mdn/dom-examples/blob/master/web-crypto/encrypt-decrypt/aes-gcm.js 3 | 4 | import { defaultBootstrapb64Key } from "./constants"; 5 | 6 | const IV_LENGTH = 12; 7 | type Base64String = string; 8 | type EncryptedBase64String = string; 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | function ab2str(buf: ArrayBuffer): string { 11 | // @ts-ignore 12 | return String.fromCharCode.apply(null, new Uint8Array(buf)); 13 | } 14 | 15 | export async function getAESKey( 16 | rawKeyData: Buffer, 17 | usages: Array<"encrypt" | "decrypt"> 18 | ): Promise { 19 | return await window.crypto.subtle.importKey( 20 | "raw", 21 | rawKeyData, 22 | { 23 | name: "AES-GCM", 24 | }, 25 | false, // extractable 26 | usages 27 | ); 28 | } 29 | 30 | export async function getBootstrapAESKey(): Promise> { 31 | try { 32 | if (!defaultBootstrapb64Key) { 33 | return null; 34 | } 35 | const keyData = Buffer.from(defaultBootstrapb64Key, "base64"); 36 | return await getAESKey(keyData, ["decrypt"]); 37 | } catch (e) { 38 | console.error(e); 39 | return null; 40 | } 41 | } 42 | 43 | export async function aesDecrypt( 44 | secretcryptoKey: CryptoKey, 45 | encryptedPayload: Buffer 46 | ): Promise { 47 | // iv is prepended to encrypted payload 48 | const iv = encryptedPayload.subarray(0, IV_LENGTH); 49 | 50 | // remaining bytes are encrypted utf-8 output of terminal 51 | const encryptedTerminalOutput = encryptedPayload.subarray(IV_LENGTH); 52 | 53 | const decryptedTerminalOutput = Buffer.from( 54 | await window.crypto.subtle.decrypt( 55 | { 56 | name: "AES-GCM", 57 | iv: iv, 58 | }, 59 | secretcryptoKey, 60 | encryptedTerminalOutput 61 | ) 62 | ); 63 | return decryptedTerminalOutput; 64 | } 65 | 66 | // https://stackoverflow.com/a/65227338/2893090 67 | function ivFromInteger(ivCount: number) { 68 | const iv = new Uint8Array(IV_LENGTH); 69 | const a = []; 70 | a.unshift(ivCount & 255); 71 | // while some other byte still has data 72 | while (ivCount >= 256) { 73 | // shift 8 bits over (consume next byte) 74 | ivCount = ivCount >>> 8; 75 | // prepend current byte value to front of the array 76 | a.unshift(ivCount & 255); 77 | } 78 | // set the 12 byte array with the array we just 79 | // computed 80 | iv.set(a); 81 | return iv; 82 | } 83 | 84 | export async function aesEncrypt( 85 | browserSecretAESKey: CryptoKey, 86 | utf8Payload: string, 87 | ivCount: number 88 | ): Promise { 89 | // The same iv must never be reused with a given key 90 | const iv = ivFromInteger(ivCount); 91 | const encryptedArrayBuffer = await window.crypto.subtle.encrypt( 92 | { 93 | name: "AES-GCM", 94 | iv: iv, 95 | }, 96 | browserSecretAESKey, 97 | new TextEncoder().encode(utf8Payload) 98 | ); 99 | // prepend unencrypted iv to encrypted payload 100 | const ivAndEncryptedPayload = _combineBuffers(iv, encryptedArrayBuffer); 101 | 102 | const base64EncryptedString = _arrayBufferToBase64(ivAndEncryptedPayload); 103 | return base64EncryptedString; 104 | } 105 | 106 | export function isIvExhausted(ivCount: number, maxIvCount: number): boolean { 107 | return ivCount >= maxIvCount; 108 | } 109 | 110 | function _combineBuffers( 111 | buffer1: Uint8Array, 112 | buffer2: ArrayBuffer 113 | ): ArrayBufferLike { 114 | const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); 115 | tmp.set(new Uint8Array(buffer1), 0); 116 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength); 117 | return tmp.buffer; 118 | } 119 | 120 | function _arrayBufferToBase64(buffer: ArrayBuffer): Base64String { 121 | const bytes = new Uint8Array(buffer); 122 | let binary = ""; 123 | const len = bytes.byteLength; 124 | for (let i = 0; i < len; i++) { 125 | // returns a utf-16 character, considered "binary" 126 | binary += String.fromCharCode(bytes[i]); 127 | } 128 | // "binary to ascii" 129 | return window.btoa(binary); 130 | } 131 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/events.tsx: -------------------------------------------------------------------------------- 1 | import { aesEncrypt } from "./encryption"; 2 | 3 | function getSalt(): string { 4 | return window.crypto.getRandomValues(new Uint8Array(12)).toString(); 5 | } 6 | 7 | export function requestTerminalDimensions() { 8 | return JSON.stringify({ event: "request_terminal_dimensions" }); 9 | } 10 | export async function newBrowserConnected(): Promise { 11 | return JSON.stringify({ 12 | event: "new_browser_connected", 13 | payload: {}, 14 | }); 15 | } 16 | 17 | export async function sendCommandToTerminal( 18 | secretEncryptionKey: CryptoKey, 19 | data: string, 20 | messageCount: number 21 | ) { 22 | return JSON.stringify({ 23 | event: "command", 24 | payload: await aesEncrypt( 25 | secretEncryptionKey, 26 | JSON.stringify({ data, salt: getSalt() }), 27 | messageCount 28 | ), 29 | }); 30 | } 31 | 32 | export function requestKeyRotation() { 33 | return JSON.stringify({ 34 | event: "request_key_rotation", 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/global.d.ts: -------------------------------------------------------------------------------- 1 | type Nullable = T | null; 2 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/index.css: -------------------------------------------------------------------------------- 1 | .Toastify__progress-bar--dark { 2 | background: gray; 3 | } 4 | 5 | code { 6 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 7 | monospace; 8 | } 9 | 10 | @tailwind base; 11 | @tailwind components; 12 | @tailwind utilities; 13 | 14 | @layer base { 15 | h1 { 16 | @apply text-2xl; 17 | } 18 | h2 { 19 | @apply text-xl; 20 | } 21 | h3 { 22 | @apply text-lg; 23 | } 24 | a { 25 | @apply underline; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cs01/termpair/543d84af21e2892a8b6f177d1c397b88973b0e98/termpair/frontend_src/src/logo.png -------------------------------------------------------------------------------- /termpair/frontend_src/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config: any) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl: any, config: any) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl: any, config: any) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/types.tsx: -------------------------------------------------------------------------------- 1 | export type AesKeysRef = { 2 | browser: Nullable; 3 | unix: Nullable; 4 | ivCount: Nullable; 5 | maxIvCount: Nullable; 6 | }; 7 | 8 | export type Status = 9 | | null 10 | | "Connecting..." 11 | | "Connection Established" 12 | | "Disconnected" 13 | | "Connection Error" 14 | | "Terminal ID is invalid" 15 | | "Browser is not running in a secure context" 16 | | "Failed to obtain encryption keys" 17 | | "Failed to fetch terminal data"; 18 | 19 | export type TerminalSize = { 20 | rows: number; 21 | cols: number; 22 | }; 23 | 24 | export type TerminalServerData = { 25 | terminal_id: string; 26 | allow_browser_control: boolean; 27 | num_clients: number; 28 | broadcast_start_time_iso: string; 29 | }; 30 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | import { debounce } from "debounce"; 3 | export function websocketUrlFromHttpUrl(httpUrl: URL) { 4 | return new URL(httpUrl.toString().replace(/^http/, "ws")); 5 | } 6 | 7 | export const toastStatus = debounce((status: any) => { 8 | toast.dark(status); 9 | }, 100); 10 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/websocketMessageHandler.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | import { xterm } from "./constants"; 3 | import { aesDecrypt, getAESKey } from "./encryption"; 4 | import { AesKeysRef, Status, TerminalSize } from "./types"; 5 | 6 | // This should be kept in sync with the Python client 7 | export type TermPairEvent = 8 | | "new_output" 9 | | "resize" 10 | | "num_clients" 11 | | "aes_keys" 12 | | "error" 13 | | "aes_key_rotation"; 14 | 15 | // TODO use something like https://www.npmjs.com/package/yup to 16 | // validate the json at runtime 17 | export const handlers = { 18 | new_output: async function ( 19 | aesKeys: React.MutableRefObject, 20 | data: any 21 | ) { 22 | if (!aesKeys.current.unix) { 23 | console.error( 24 | "Missing AES CryptoKey for unix terminal. Cannot decrypt message." 25 | ); 26 | return; 27 | } 28 | const decryptedJson = await aesDecrypt( 29 | aesKeys.current.unix, 30 | Buffer.from(data.payload, "base64") 31 | ); 32 | const decryptedPayload = JSON.parse(decryptedJson.toString()); 33 | const pty_output = Buffer.from(decryptedPayload.pty_output, "base64"); 34 | xterm.write(pty_output); 35 | }, 36 | resize: function ( 37 | data: any, 38 | setTerminalSize: React.Dispatch> 39 | ) { 40 | if (data.payload.cols && data.payload.rows) { 41 | const cols = data.payload.cols; 42 | const rows = data.payload.rows; 43 | setTerminalSize({ 44 | cols, 45 | rows, 46 | }); 47 | xterm.resize(cols, rows); 48 | } 49 | }, 50 | num_clients: function ( 51 | setNumClients: React.Dispatch>, 52 | data: any 53 | ) { 54 | const num_clients = data.payload; 55 | setNumClients(num_clients); 56 | }, 57 | aes_keys: async function ( 58 | aesKeys: React.MutableRefObject, 59 | bootstrapAesKey: CryptoKey, 60 | data: any, 61 | changeStatus: (newStatus: Status) => void 62 | ) { 63 | try { 64 | const unixAesKeyData = await aesDecrypt( 65 | bootstrapAesKey, 66 | Buffer.from(data.payload.b64_bootstrap_unix_aes_key, "base64") 67 | ); 68 | aesKeys.current.unix = await getAESKey(unixAesKeyData, ["decrypt"]); 69 | 70 | const browserAesKeyData = await aesDecrypt( 71 | bootstrapAesKey, 72 | Buffer.from(data.payload.b64_bootstrap_browser_aes_key, "base64") 73 | ); 74 | aesKeys.current.browser = await getAESKey(browserAesKeyData, ["encrypt"]); 75 | if (data.payload.iv_count == null || data.payload.max_iv_count == null) { 76 | console.error("missing required iv parameters"); 77 | throw Error("missing required iv parameters"); 78 | } 79 | const startIvCount = (aesKeys.current.ivCount = parseInt( 80 | data.payload.iv_count, 81 | 10 82 | )); 83 | 84 | const maxIvCount = (aesKeys.current.maxIvCount = parseInt( 85 | data.payload.max_iv_count, 86 | 10 87 | )); 88 | if (maxIvCount < startIvCount) { 89 | console.error( 90 | `Initialized IV counter is below max value ${startIvCount} vs ${maxIvCount}` 91 | ); 92 | aesKeys.current = { 93 | ...aesKeys.current, 94 | browser: null, 95 | maxIvCount: null, 96 | ivCount: null, 97 | unix: null, 98 | }; 99 | throw Error; 100 | } 101 | } catch (e) { 102 | if ( 103 | aesKeys.current.browser == null || 104 | aesKeys.current.unix == null || 105 | aesKeys.current.ivCount == null || 106 | aesKeys.current.maxIvCount == null 107 | ) { 108 | console.error(e); 109 | console.error(data); 110 | changeStatus("Failed to obtain encryption keys"); 111 | return; 112 | } 113 | } 114 | }, 115 | aes_key_rotation: async function ( 116 | aesKeys: React.MutableRefObject, 117 | data: any 118 | ) { 119 | if (!aesKeys.current.unix) { 120 | console.error("Cannot decrypt new AES keys"); 121 | return; 122 | } 123 | try { 124 | const newUnixAesKeyData = await aesDecrypt( 125 | aesKeys.current.unix, 126 | data.payload.b64_aes_secret_unix_key 127 | ); 128 | const newBrowserAesKeyData = await aesDecrypt( 129 | aesKeys.current.unix, 130 | Buffer.from(data.payload.b64_aes_secret_browser_key, "base64") 131 | ); 132 | aesKeys.current.browser = await getAESKey(newBrowserAesKeyData, [ 133 | "encrypt", 134 | ]); 135 | aesKeys.current.unix = await getAESKey(newUnixAesKeyData, ["decrypt"]); 136 | // toast.dark("AES keys have been rotated"); 137 | } catch (e) { 138 | console.error(e); 139 | toast.dark(`AES key rotation failed: ${e}`); 140 | } 141 | }, 142 | error: function (data: any) { 143 | toast.dark(`Error: ${data.payload}`); 144 | console.error(data); 145 | }, 146 | default: function (data: any) { 147 | toast.dark(`Unknown event received: ${data.event}`); 148 | console.error("unknown event type", data); 149 | }, 150 | }; 151 | -------------------------------------------------------------------------------- /termpair/frontend_src/src/xtermUtils.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | import { Terminal as Xterm } from "xterm"; 3 | import { cannotTypeMsg } from "./constants"; 4 | import { isIvExhausted } from "./encryption"; 5 | import { sendCommandToTerminal, requestKeyRotation } from "./events"; 6 | import { AesKeysRef, TerminalServerData } from "./types"; 7 | import { toastStatus } from "./utils"; 8 | /** 9 | * The API to xterm.attachCustomKeyEventHandler is hardcoded. This function 10 | * provides a closure so that other variables can be used inside it. 11 | * 12 | * https://github.com/xtermjs/xterm.js/blob/70babeacb62fe05264d64324ca1f4436997efa1b/typings/xterm.d.ts#L538-L547 13 | * 14 | * @param {*} terminal - xterm object 15 | * @param {*} canType - is user allowed to type (this is also enforced on the server) 16 | * @param {*} sendInputToTerminal - function to encode and send input over the websocket 17 | * @returns nothing 18 | */ 19 | export function getCustomKeyEventHandler( 20 | terminal: Xterm, 21 | canType: boolean | void, 22 | sendInputToTerminal: (input: string) => void 23 | ) { 24 | /** 25 | * Custom key event handler which is run before keys are 26 | * processed, giving consumers of xterm.js ultimate control as to what keys 27 | * should be processed by the terminal and what keys should not. 28 | * @param customKeyEventHandler The custom KeyboardEvent handler to attach. 29 | * This is a function that takes a KeyboardEvent, allowing consumers to stop 30 | * propagation and/or prevent the default action. The function returns 31 | * whether the event should be processed by xterm.js. 32 | */ 33 | function customKeyEventHandler(e: KeyboardEvent): boolean { 34 | if (e.type !== "keydown") { 35 | return true; 36 | } 37 | if (e.ctrlKey && e.shiftKey) { 38 | const key = e.key.toLowerCase(); 39 | if (key === "v") { 40 | if (!canType) { 41 | toastStatus(cannotTypeMsg); 42 | return false; 43 | } 44 | navigator.clipboard.readText().then((toPaste) => { 45 | sendInputToTerminal(toPaste); 46 | }); 47 | return false; 48 | } else if (key === "c" || key === "x") { 49 | // 'x' is used as an alternate to 'c' because ctrl+c is taken 50 | // by the terminal (SIGINT) and ctrl+shift+c is taken by the browser 51 | // (open devtools). 52 | // I'm not aware of ctrl+shift+x being used by anything in the terminal 53 | // or browser 54 | const toCopy = terminal.getSelection(); 55 | navigator.clipboard.writeText(toCopy); 56 | terminal.focus(); 57 | return false; 58 | } 59 | } 60 | return true; 61 | } 62 | 63 | return customKeyEventHandler; 64 | } 65 | 66 | export function redXtermText(text: string): string { 67 | return "\x1b[1;31m" + text + "\x1b[0m"; 68 | } 69 | 70 | export function getOnDataHandler( 71 | ws: WebSocket, 72 | terminalServerData: TerminalServerData, 73 | aesKeys: React.MutableRefObject 74 | ) { 75 | return async (newInput: any) => { 76 | try { 77 | if (terminalServerData.allow_browser_control === false) { 78 | toastStatus(cannotTypeMsg); 79 | return; 80 | } 81 | if ( 82 | aesKeys.current.browser === null || 83 | aesKeys.current.ivCount === null || 84 | aesKeys.current.maxIvCount === null 85 | ) { 86 | toast.dark( 87 | `Cannot input because it cannot be encrypted. Encryption keys are missing.` 88 | ); 89 | return; 90 | } 91 | ws.send( 92 | await sendCommandToTerminal( 93 | aesKeys.current.browser, 94 | newInput, 95 | aesKeys.current.ivCount++ 96 | ) 97 | ); 98 | if (isIvExhausted(aesKeys.current.ivCount, aesKeys.current.maxIvCount)) { 99 | ws.send(requestKeyRotation()); 100 | aesKeys.current.maxIvCount += 1000; 101 | } 102 | } catch (e) { 103 | toast.dark(`Failed to send data to terminal ${e}`); 104 | } 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /termpair/frontend_src/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [require('@tailwindcss/aspect-ratio')], 11 | }; 12 | -------------------------------------------------------------------------------- /termpair/frontend_src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /termpair/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import asyncio 5 | import os 6 | import shlex 7 | import traceback 8 | from urllib.parse import urlparse 9 | 10 | import uvicorn # type: ignore 11 | from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware # type: ignore 12 | 13 | from . import server, share 14 | from .constants import TERMPAIR_VERSION, TermPairError 15 | 16 | __version__ = TERMPAIR_VERSION 17 | 18 | 19 | def get_parser(): 20 | p = argparse.ArgumentParser( 21 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 22 | description="View and control remote terminals from your browser", 23 | ) 24 | p.add_argument("--version", action="version", version=TERMPAIR_VERSION) 25 | subparsers = p.add_subparsers(dest="command", required=True) 26 | 27 | share_parser = subparsers.add_parser( 28 | "share", 29 | description=( 30 | "Share your terminal session with one or more browsers. " 31 | "A termpair server must be running before using this command." 32 | ), 33 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 34 | ) 35 | share_parser.add_argument( 36 | "--cmd", 37 | default=os.environ.get("SHELL", "bash"), 38 | help=( 39 | "The command to run in this TermPair session. " 40 | "Defaults to the SHELL environment variable" 41 | ), 42 | ) 43 | share_parser.add_argument( 44 | "--port", "-p", default=8000, help="port server is running on" 45 | ) 46 | share_parser.add_argument( 47 | "--host", default="http://localhost", help="host server is running on" 48 | ) 49 | share_parser.add_argument( 50 | "--read-only", 51 | "-r", 52 | action="store_true", 53 | help="Do not allow browsers to write to the terminal", 54 | ) 55 | share_parser.add_argument( 56 | "--open-browser", 57 | "-b", 58 | action="store_true", 59 | help="Open a browser tab to the terminal after you start sharing", 60 | ) 61 | 62 | server_parser = subparsers.add_parser( 63 | "serve", 64 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 65 | description=( 66 | "Run termpair server to route messages between unix terminals and browsers. " 67 | "Run this before connecting any clients. " 68 | "TermPair only works in secure contexts; SSL/TLS is generally required. " 69 | "To generate an SSL certificate and private key, run " 70 | "`openssl req -newkey rsa:2048 -nodes -keyout host.key -x509 -days 365 -out host.crt`. " 71 | "To skip questions and use defaults, add the `-batch` flag. " 72 | "You can ignore warnings about self-signed certificates since you know you just made it. " 73 | "Then use them, pass the '--certfile' and '--keyfile' arguments." 74 | ), 75 | ) 76 | server_parser.add_argument( 77 | "--port", "-p", default=8000, help="Port to run the server on" 78 | ) 79 | server_parser.add_argument( 80 | "--host", 81 | default="localhost", 82 | help="Host to run the server on (0.0.0.0 exposes publicly)", 83 | ) 84 | server_parser.add_argument( 85 | "--certfile", 86 | "-c", 87 | help="Path to SSL certificate file (commonly .crt extension)", 88 | ) 89 | server_parser.add_argument( 90 | "--keyfile", 91 | "-k", 92 | help="Path to SSL private key .key file (commonly .key extension)", 93 | ) 94 | return p 95 | 96 | 97 | def run_command(args): 98 | if args.command == "share": 99 | cmd = shlex.split(args.cmd) 100 | 101 | if not args.host.startswith("http://") and not args.host.startswith("https://"): 102 | exit("host must start with either http:// or https://") 103 | 104 | parsed = urlparse(args.host) 105 | if args.port: 106 | url = f"{parsed.scheme}://{parsed.netloc}:{args.port}{parsed.path}" 107 | else: 108 | url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" 109 | url = url if url.endswith("/") else f"{url}/" 110 | allow_browser_control = not args.read_only 111 | try: 112 | asyncio.get_event_loop().run_until_complete( 113 | share.broadcast_terminal( 114 | cmd, url, allow_browser_control, args.open_browser 115 | ) 116 | ) 117 | except TermPairError as e: 118 | exit(e) 119 | 120 | elif args.command == "serve": 121 | if args.certfile or args.keyfile: 122 | server.app.add_middleware(HTTPSRedirectMiddleware) 123 | 124 | uvicorn.run( 125 | server.app, 126 | host=args.host, 127 | port=int(args.port), 128 | ssl_certfile=args.certfile, 129 | ssl_keyfile=args.keyfile, 130 | ) 131 | 132 | 133 | def main(): 134 | args = get_parser().parse_args() 135 | try: 136 | run_command(args) 137 | except Exception: 138 | print( 139 | "TermPair encountered an error. If you think this is a bug, it can be reported at https://github.com/cs01/termpair/issues" 140 | ) 141 | print("") 142 | exit(traceback.format_exc()) 143 | 144 | 145 | if __name__ == "__main__": 146 | main() 147 | -------------------------------------------------------------------------------- /termpair/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Server to receive all output from user's terminal and forward on to any 3 | browsers that are watching the broadcast 4 | """ 5 | 6 | 7 | import asyncio 8 | import json 9 | import logging 10 | import os 11 | import time 12 | from hashlib import md5 13 | from typing import Any, Dict, List, Optional 14 | 15 | import starlette # type: ignore 16 | from fastapi import FastAPI # type: ignore 17 | from fastapi.exceptions import HTTPException # type: ignore 18 | from fastapi.middleware.cors import CORSMiddleware # type:ignore 19 | from starlette.staticfiles import StaticFiles # type: ignore 20 | from starlette.websockets import WebSocket # type: ignore 21 | 22 | from .constants import TERMPAIR_VERSION 23 | from .server_websocket_subprotocol_handlers import handle_ws_message_subprotocol_v3 24 | from .Terminal import Terminal, TerminalId 25 | from .utils import get_random_string 26 | 27 | PUBLIC_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "frontend_build") 28 | STATIC_DIR = os.path.join( 29 | os.path.dirname(os.path.realpath(__file__)), "frontend_build/static" 30 | ) 31 | 32 | app = FastAPI() 33 | 34 | app.add_middleware( 35 | CORSMiddleware, 36 | allow_origins=[ 37 | "*", 38 | ], 39 | allow_credentials=True, 40 | allow_methods=["*"], 41 | allow_headers=["*"], 42 | ) 43 | 44 | terminals: Dict[TerminalId, Terminal] = {} 45 | 46 | 47 | @app.get("/ping") 48 | async def ping(): 49 | return "pong" 50 | 51 | 52 | @app.get("/terminal/{terminal_id}") 53 | async def index(terminal_id: Optional[TerminalId] = None): 54 | from .main import __version__ 55 | 56 | terminal = None 57 | if terminal_id: 58 | terminal = terminals.get(terminal_id) 59 | 60 | data: Dict[str, Any] 61 | 62 | if not terminal: 63 | raise HTTPException(status_code=404, detail="Terminal not found") 64 | 65 | rows = terminal.rows 66 | cols = terminal.cols 67 | allow_browser_control = terminal.allow_browser_control 68 | data = dict( 69 | terminal_id=terminal_id, 70 | cols=cols, 71 | rows=rows, 72 | allow_browser_control=allow_browser_control, 73 | command=terminal.command, 74 | broadcast_start_time_iso=terminal.broadcast_start_time_iso, 75 | termpair_version=__version__, 76 | ) 77 | return data 78 | 79 | 80 | @app.websocket("/connect_browser_to_terminal") 81 | async def connect_browser_to_terminal(ws: WebSocket): 82 | terminal_id = ws.query_params.get("terminal_id", None) 83 | terminal = terminals.get(terminal_id) 84 | if not terminal: 85 | print(f"terminal id {terminal_id} not found") 86 | await ws.close() 87 | return 88 | await ws.accept() 89 | 90 | terminal.browser_websockets.append(ws) 91 | 92 | # Need to create a task so it can be cancelled by the terminal's 93 | # task if the terminal session ends. That way, browsers are notified 94 | # the session ended instead of thinking the connection is still open 95 | # (an exception raised while awaiting) 96 | task: asyncio.Task = asyncio.create_task( 97 | _task_handle_browser_websocket(terminal, ws) 98 | ) 99 | 100 | def remove_task_from_terminal_list(future): 101 | # task will sit in list as "done" 102 | # not a big deal if it sits there, but we'll remove it 103 | # immediately since it's never going to be used again 104 | terminal.browser_tasks.remove(task) 105 | 106 | task.add_done_callback(remove_task_from_terminal_list) 107 | terminal.browser_tasks.append(task) 108 | try: 109 | # task will be cancelled when terminal session the client started ends 110 | await task 111 | except asyncio.exceptions.CancelledError: 112 | pass 113 | 114 | 115 | async def _task_handle_browser_websocket(terminal: Terminal, ws: WebSocket): 116 | try: 117 | # update connected browser count in each browser 118 | num_browsers = len(terminal.browser_websockets) 119 | for browser in terminal.browser_websockets: 120 | await browser.send_json({"event": "num_clients", "payload": num_browsers}) 121 | while True: 122 | await handle_ws_message_subprotocol_v3(ws, terminal) 123 | 124 | except starlette.websockets.WebSocketDisconnect: 125 | # browser closed the connection 126 | pass 127 | finally: 128 | if ws in terminal.browser_websockets: 129 | terminal.browser_websockets.remove(ws) 130 | num_browsers = len(terminal.browser_websockets) 131 | for web_client in terminal.browser_websockets: 132 | await web_client.send_json( 133 | {"event": "num_clients", "payload": num_browsers} 134 | ) 135 | 136 | 137 | async def forward_terminal_data_to_web_clients(terminal: Terminal): 138 | while True: 139 | # The task is to endlessly wait for new data from the terminal, 140 | # read it, and broadcast it to all connected browsers 141 | ws = terminal.ws 142 | browser_websockets = terminal.browser_websockets 143 | try: 144 | data = await ws.receive_json() 145 | except starlette.websockets.WebSocketDisconnect: 146 | # Terminal stopped broadcasting, close 147 | # all browser websocket tasks so they are notified 148 | # the connection has actually ended 149 | for task in terminal.browser_tasks: 150 | task.cancel() 151 | return 152 | 153 | terminal_has_closed = False 154 | event = data.get("event") 155 | if event == "new_output": 156 | terminal_data = data.get("payload") 157 | terminal_has_closed = not terminal_data 158 | elif event == "resize": 159 | # namedtuples require you to replace fields 160 | terminal._replace(rows=data["payload"]["rows"]) 161 | terminal._replace(cols=data["payload"]["cols"]) 162 | elif event in ["aes_keys", "aes_key_rotation"]: 163 | pass 164 | else: 165 | logging.warning(f"Got unknown event {data.get('event', 'none')}") 166 | 167 | if terminal_has_closed: 168 | # terminal outputs an empty string when it closes, so it just closed 169 | for browser_ws in browser_websockets: 170 | # close each browser connection since the terminal's broadcasting 171 | # process stopped 172 | await browser_ws.close() 173 | return 174 | 175 | browsers_to_remove: List[WebSocket] = [] 176 | for browser_ws in browser_websockets: 177 | try: 178 | await browser_ws.send_json(data) 179 | except Exception: 180 | if browser_ws not in browsers_to_remove: 181 | browsers_to_remove.append(browser_ws) 182 | 183 | if browsers_to_remove: 184 | for browser_ws in browsers_to_remove: 185 | browser_websockets.remove(browser_ws) 186 | 187 | # let still-connected clients know the new count 188 | for browser_ws in browser_websockets: 189 | await browser_ws.send_json( 190 | {"event": "num_clients", "payload": len(browser_websockets)} 191 | ) 192 | # continue running task in while loop 193 | 194 | 195 | def _gen_terminal_id(ws: WebSocket) -> TerminalId: 196 | random = str(str(time.time()) + get_random_string(30)) 197 | checksum = md5(random.encode()) 198 | return TerminalId(checksum.hexdigest()) 199 | 200 | 201 | @app.websocket("/connect_to_terminal") 202 | async def connect_to_terminal(ws: WebSocket): 203 | await ws.accept() 204 | data = await ws.receive_json() 205 | subprotocol_version = data.get("subprotocol_version") 206 | valid_subprotocols = ["3"] 207 | if subprotocol_version not in valid_subprotocols: 208 | await ws.send_text( 209 | json.dumps( 210 | { 211 | "event": "fatal_error", 212 | "payload": "Client and server are running incompatible versions. " 213 | + f"Server is running v{TERMPAIR_VERSION}. " 214 | + "Ensure you are using a version of the TermPair client compatible with the server. ", 215 | } 216 | ) 217 | ) 218 | await ws.close() 219 | return 220 | 221 | terminal_id = _gen_terminal_id(ws) 222 | terminal = Terminal( 223 | ws=ws, 224 | browser_websockets=[], 225 | browser_tasks=[], 226 | rows=data["rows"], 227 | cols=data["cols"], 228 | allow_browser_control=data["allow_browser_control"], 229 | command=data["command"], 230 | broadcast_start_time_iso=data["broadcast_start_time_iso"], 231 | subprotocol_version=subprotocol_version, 232 | ) 233 | terminals[terminal_id] = terminal 234 | 235 | # send back to the terminal that the broadcast is starting under 236 | # this id 237 | await ws.send_text(json.dumps({"event": "start_broadcast", "payload": terminal_id})) 238 | 239 | # forwards all data from the terminal to browsers for as long as the 240 | # client is connected 241 | await asyncio.ensure_future(forward_terminal_data_to_web_clients(terminal)) 242 | terminals.pop(terminal_id, None) 243 | 244 | 245 | app.mount("/", StaticFiles(directory=PUBLIC_DIR, html=True)) 246 | app.mount("/static", StaticFiles(directory=STATIC_DIR, html=True)) 247 | -------------------------------------------------------------------------------- /termpair/server_websocket_subprotocol_handlers.py: -------------------------------------------------------------------------------- 1 | from starlette.websockets import WebSocket # type: ignore 2 | 3 | from .Terminal import Terminal 4 | 5 | 6 | async def handle_ws_message_subprotocol_v3(ws: WebSocket, terminal: Terminal): 7 | browser_input = await ws.receive_json() 8 | event = browser_input.get("event") 9 | if event == "command": 10 | if terminal.allow_browser_control: 11 | await terminal.ws.send_json(browser_input) 12 | else: 13 | await terminal.ws.send_json(browser_input) 14 | -------------------------------------------------------------------------------- /termpair/share.py: -------------------------------------------------------------------------------- 1 | """ 2 | Establish a websocket connection and replace local terminal with a pty 3 | that sends all output to the server. 4 | """ 5 | 6 | import asyncio 7 | import base64 8 | import datetime 9 | import json 10 | import os 11 | import pty 12 | import signal 13 | import ssl 14 | import sys 15 | import webbrowser 16 | from math import floor 17 | from typing import Callable, List, Optional 18 | from urllib.parse import urlencode, urljoin 19 | 20 | import websockets # type: ignore 21 | 22 | from . import encryption, utils 23 | from .constants import TermPairError, subprotocol_version 24 | from .Terminal import TerminalId 25 | 26 | max_read_bytes = 1024 * 2 27 | ws_queue: asyncio.Queue = asyncio.Queue() 28 | JS_MAX_SAFE_INTEGER = 2**53 - 1 29 | 30 | 31 | class AesKeys: 32 | message_count: int 33 | 34 | def __init__(self): 35 | self.bootstrap_message_count = 0 36 | self.message_count = 0 37 | self.message_count_rotation_required = 2**20 38 | self.browser_rotation_buffer_count = self.message_count_rotation_required * 0.1 39 | self.secret_bootstrap_key = encryption.aes_generate_secret_key() 40 | self.secret_unix_key = encryption.aes_generate_secret_key() 41 | self.secret_browser_key = encryption.aes_generate_secret_key() 42 | 43 | def encrypt_bootstrap(self, plaintext: bytes): 44 | self.bootstrap_message_count += 1 45 | return encryption.aes_encrypt( 46 | self.message_count, self.secret_bootstrap_key, plaintext 47 | ) 48 | return encryption.aes_encrypt_with_random(self.secret_bootstrap_key, plaintext) 49 | 50 | def encrypt(self, plaintext: bytes): 51 | self.message_count += 1 52 | # encrypt with our AES key 53 | return encryption.aes_encrypt( 54 | self.message_count, self.secret_unix_key, plaintext 55 | ) 56 | 57 | def decrypt(self, ciphertext: bytes) -> str: 58 | # decrypt with browser's AES key 59 | plaintext = encryption.aes_decrypt(self.secret_browser_key, ciphertext) 60 | return plaintext 61 | 62 | def get_max_iv_for_browser(self, start_iv_count: int) -> int: 63 | # each browser for this session encrypts using the same AES key. 64 | # To avoid re-using an IV, we assign each a window to operate within. 65 | # If the end of the window is hit, a new key is requested. 66 | max_iv_count = floor( 67 | start_iv_count 68 | + self.message_count_rotation_required 69 | - self.browser_rotation_buffer_count 70 | ) 71 | if max_iv_count > JS_MAX_SAFE_INTEGER or max_iv_count < start_iv_count: 72 | raise TermPairError("Cannot create safe AES nonce") 73 | return max_iv_count 74 | 75 | def get_start_iv_count(self, browser_number: int) -> int: 76 | start_iv_count = (browser_number - 1) * self.message_count_rotation_required 77 | if start_iv_count > JS_MAX_SAFE_INTEGER: 78 | raise TermPairError("Cannot create safe AES nonce") 79 | return start_iv_count 80 | 81 | @property 82 | def need_rotation(self) -> bool: 83 | return self.message_count > self.message_count_rotation_required 84 | 85 | def rotate_keys(self): 86 | new_unix_key = encryption.aes_generate_secret_key() 87 | new_browser_key = encryption.aes_generate_secret_key() 88 | 89 | ws_queue.put_nowait( 90 | json.dumps( 91 | { 92 | "event": "aes_key_rotation", 93 | "payload": { 94 | "b64_aes_secret_unix_key": base64.b64encode( 95 | self.encrypt(new_unix_key) 96 | ).decode(), 97 | "b64_aes_secret_browser_key": base64.b64encode( 98 | self.encrypt(new_browser_key) 99 | ).decode(), 100 | }, 101 | } 102 | ) 103 | ) 104 | self.secret_unix_key = new_unix_key 105 | self.secret_browser_key = new_browser_key 106 | self.message_count = 0 107 | 108 | 109 | class SharingSession: 110 | stdout_fd: int 111 | num_browsers: int 112 | 113 | def __init__( 114 | self, 115 | url: str, 116 | cmd: List[str], 117 | pty_fd: int, 118 | stdin_fd: int, 119 | stdout_fd: int, 120 | ws, 121 | open_browser: bool, 122 | allow_browser_control: bool, 123 | ): 124 | self.url = url 125 | self.cmd = cmd 126 | self.pty_fd = pty_fd 127 | self.stdin_fd = stdin_fd 128 | self.stdout_fd = stdout_fd 129 | self.ws = ws 130 | self.open_browser = open_browser 131 | self.allow_browser_control = allow_browser_control 132 | self.aes_keys = AesKeys() 133 | self.terminal_id = None 134 | self.num_browsers = 0 135 | 136 | async def register_broadcast_with_server(self) -> TerminalId: 137 | """Prepare server to store i/o about this terminal""" 138 | # copy our terminal dimensions to the pty so its row/col count 139 | # matches and we don't get unexpected line breaks/misalignments 140 | utils.copy_terminal_dimensions(self.stdin_fd, self.pty_fd) 141 | 142 | rows, cols = utils.get_terminal_size(self.stdin_fd) 143 | 144 | cmd_str = " ".join(self.cmd) 145 | broadcast_start_time_iso = datetime.datetime.now( 146 | datetime.timezone.utc 147 | ).isoformat() 148 | await self.ws.send( 149 | json.dumps( 150 | { 151 | "rows": rows, 152 | "cols": cols, 153 | "allow_browser_control": self.allow_browser_control, 154 | "command": cmd_str, 155 | "broadcast_start_time_iso": broadcast_start_time_iso, 156 | "subprotocol_version": subprotocol_version, 157 | } 158 | ) 159 | ) 160 | event, payload = await self.receive_data_from_websocket() 161 | if event == "start_broadcast": 162 | return payload 163 | elif event == "fatal_error": 164 | raise TermPairError(fatal_server_error_msg(payload)) 165 | else: 166 | raise TermPairError( 167 | "Unexpected event type received when starting broadcast. " 168 | + "Ensure you are using a compatible version with the server.", 169 | event, 170 | ) 171 | 172 | async def run(self): 173 | self.terminal_id = await self.register_broadcast_with_server() 174 | 175 | self.print_broadcast_init_message() 176 | 177 | with utils.make_raw(self.stdin_fd): 178 | await self.do_broadcast() 179 | 180 | async def do_broadcast(self): 181 | signal.signal( 182 | signal.SIGWINCH, 183 | lambda signum, frame: self.emit_terminal_dimensions(), 184 | ) 185 | if self.open_browser: 186 | webbrowser.open(self.share_url) 187 | 188 | tasks = [ 189 | asyncio.ensure_future(self.task_send_ws_queue_to_server()), 190 | asyncio.ensure_future(self.task_receive_websocket_messages()), 191 | ] 192 | 193 | loop = asyncio.get_event_loop() 194 | 195 | def cleanup(): 196 | for t in tasks: 197 | t.cancel() 198 | loop.remove_reader(self.stdin_fd) 199 | loop.remove_reader(self.pty_fd) 200 | 201 | # add event-based reading of input to stdin, and forward to the pty 202 | # process 203 | loop.add_reader(self.stdin_fd, self.handle_new_stdin) 204 | 205 | # add event based reading of output from the pty and write to 206 | # stdout and to the server 207 | loop.add_reader( 208 | self.pty_fd, 209 | self.handle_new_pty_output, 210 | cleanup, 211 | ) 212 | 213 | done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 214 | for task in pending: 215 | task.cancel() 216 | 217 | def handle_new_stdin(self): 218 | """forwards from terminal's stdin to the pty's stdin""" 219 | user_input = os.read(self.stdin_fd, max_read_bytes) 220 | os.write(self.pty_fd, user_input) 221 | 222 | def emit_terminal_dimensions(self): 223 | utils.copy_terminal_dimensions(self.stdin_fd, self.pty_fd) 224 | rows, cols = utils.get_terminal_size(self.stdin_fd) 225 | ws_queue.put_nowait( 226 | json.dumps({"event": "resize", "payload": {"rows": rows, "cols": cols}}) 227 | ) 228 | 229 | async def task_receive_websocket_messages(self): 230 | """receives events+payloads from browser websocket connection""" 231 | try: 232 | while True: 233 | event, payload = await self.receive_data_from_websocket() 234 | if event == "command": 235 | if self.allow_browser_control: 236 | try: 237 | encrypted_payload = base64.b64decode(payload) 238 | json_data = self.aes_keys.decrypt(encrypted_payload) 239 | data = json.loads(json_data)["data"] 240 | os.write(self.pty_fd, data.encode()) 241 | except Exception: 242 | pass 243 | elif event == "request_terminal_dimensions": 244 | self.emit_terminal_dimensions() 245 | elif event == "request_key_rotation": 246 | self.aes_keys.rotate_keys() 247 | elif event == "new_browser_connected": 248 | # no payload required, just the event. 249 | # the browser must already have 250 | # the bootstrap AES key to decrypt the 251 | # message we're about to 252 | self.num_browsers += 1 253 | try: 254 | # use the bootstrap AES key to encrypt the two AES keys 255 | # used for terminal input/output 256 | b64_bootstrap_unix_aes_key = base64.b64encode( 257 | self.aes_keys.encrypt_bootstrap( 258 | self.aes_keys.secret_unix_key 259 | ) 260 | ).decode() 261 | b64_bootstrap_browser_aes_key = base64.b64encode( 262 | self.aes_keys.encrypt_bootstrap( 263 | self.aes_keys.secret_browser_key 264 | ) 265 | ).decode() 266 | 267 | iv_count = self.aes_keys.get_start_iv_count(self.num_browsers) 268 | ws_queue.put_nowait( 269 | json.dumps( 270 | { 271 | "event": "aes_keys", 272 | "payload": { 273 | "b64_bootstrap_unix_aes_key": b64_bootstrap_unix_aes_key, 274 | "b64_bootstrap_browser_aes_key": b64_bootstrap_browser_aes_key, 275 | "iv_count": iv_count, 276 | "max_iv_count": self.aes_keys.get_max_iv_for_browser( 277 | iv_count 278 | ), 279 | "salt": base64.b64encode( 280 | os.urandom(12) 281 | ).decode(), 282 | }, 283 | } 284 | ) 285 | ) 286 | except Exception as e: 287 | print(e) 288 | elif event == "fatal_error": 289 | raise fatal_server_error_msg(payload) 290 | else: 291 | # TODO log to a file 292 | pass 293 | except websockets.exceptions.ConnectionClosed: 294 | return 295 | 296 | async def task_send_ws_queue_to_server(self): 297 | """Waits for new pty output (nonblocking), then immediately sends to server""" 298 | while True: 299 | data = await ws_queue.get() 300 | await self.ws.send(data) 301 | 302 | async def receive_data_from_websocket(self): 303 | data = await self.ws.recv() 304 | parsed = json.loads(data) 305 | return parsed["event"], parsed.get("payload") 306 | 307 | def print_broadcast_init_message(self): 308 | _, cols = utils.get_terminal_size(sys.stdin) 309 | secret_key_b64 = base64.b64encode(self.aes_keys.secret_bootstrap_key).decode() 310 | msg = [ 311 | "\033[1m\033[0;32mConnection established with end-to-end encryption\033[0m 🔒", 312 | "", 313 | "Shareable link: " 314 | + self.get_share_url(self.url, self.terminal_id, secret_key_b64), 315 | "", 316 | f"Terminal ID: {self.terminal_id}", 317 | f"Secret encryption key: {secret_key_b64}", 318 | f"TermPair Server URL: {self.url}", 319 | "", 320 | "Type 'exit' or close terminal to stop sharing.", 321 | ] 322 | 323 | dashes = "-" * cols 324 | print(dashes) 325 | for m in msg: 326 | print(m) 327 | print(dashes) 328 | 329 | def get_share_url( 330 | self, 331 | url: str, 332 | terminal_id: str, 333 | secret_key_b64: str, 334 | ): 335 | # if static_url: 336 | # qp = { 337 | # "terminal_id": terminal_id, 338 | # "termpair_server_url": url, 339 | # } 340 | # return urljoin(static_url, f"?{urlencode(qp)}") 341 | # else: 342 | 343 | qp = { 344 | "terminal_id": terminal_id, 345 | } 346 | return urljoin(url, f"?{urlencode(qp)}#{secret_key_b64}") 347 | 348 | def handle_new_pty_output(self, cleanup: Callable): 349 | """forwards pty's output to local stdout AND to websocket""" 350 | try: 351 | pty_output = os.read(self.pty_fd, max_read_bytes) 352 | except OSError: 353 | cleanup() 354 | return 355 | 356 | if pty_output: 357 | # forward output to user's terminal 358 | os.write(self.stdout_fd, pty_output) 359 | 360 | # also forward output to the server so it can forward to connected browsers 361 | plaintext_payload = json.dumps( 362 | { 363 | "pty_output": base64.b64encode(pty_output).decode(), 364 | "salt": base64.b64encode(os.urandom(12)).decode("utf8"), 365 | } 366 | ) 367 | encrypted_payload = self.aes_keys.encrypt(plaintext_payload.encode()) 368 | ws_queue.put_nowait( 369 | json.dumps( 370 | { 371 | "event": "new_output", 372 | "payload": base64.b64encode(encrypted_payload).decode(), 373 | } 374 | ) 375 | ) 376 | if self.aes_keys.need_rotation: 377 | self.aes_keys.rotate_keys() 378 | else: 379 | cleanup() 380 | return 381 | 382 | 383 | def fatal_server_error_msg(error_msg: str): 384 | raise TermPairError("Connection was terminated with a fatal error: " + error_msg) 385 | 386 | 387 | async def broadcast_terminal( 388 | cmd: List[str], url: str, allow_browser_control: bool, open_browser: bool 389 | ): 390 | """Fork this process and connect it to websocket to broadcast it""" 391 | # create child process attached to a pty we can read from and write to 392 | 393 | (child_pid, pty_fd) = pty.fork() 394 | if child_pid == 0: 395 | # This is the forked process. Replace it with the shell command 396 | # the user wants to run. 397 | env = os.environ.copy() 398 | env["TERMPAIR_BROADCASTING"] = "1" 399 | env["TERMPAIR_BROWSERS_CAN_CONTROL"] = "1" if allow_browser_control else "0" 400 | os.execvpe(cmd[0], cmd, env) 401 | return 402 | 403 | stdin_fd = sys.stdin.fileno() 404 | stdout_fd = sys.stdout.fileno() 405 | 406 | ssl_context: Optional[ssl.SSLContext] = ( 407 | ssl.SSLContext(ssl.PROTOCOL_TLS) if url.startswith("https") else None 408 | ) 409 | 410 | ws_url = url.replace("http", "ws") 411 | 412 | ws_endpoint = urljoin(ws_url, "connect_to_terminal") 413 | try: 414 | async with websockets.connect(ws_endpoint, ssl=ssl_context) as ws: 415 | sharing_session = SharingSession( 416 | url, 417 | cmd, 418 | pty_fd, 419 | stdin_fd, 420 | stdout_fd, 421 | ws, 422 | open_browser, 423 | allow_browser_control, 424 | ) 425 | await sharing_session.run() 426 | print( 427 | f"You are no longer broadcasting terminal id {sharing_session.terminal_id}" 428 | ) 429 | except ConnectionRefusedError as e: 430 | raise TermPairError( 431 | "Connection was refused. Is the TermPair server running on the host and port specified? " 432 | + str(e), 433 | ) 434 | -------------------------------------------------------------------------------- /termpair/utils.py: -------------------------------------------------------------------------------- 1 | import fcntl 2 | import random 3 | import string 4 | import struct 5 | import termios 6 | import tty 7 | from contextlib import contextmanager 8 | 9 | max_read_bytes = 1024 * 20 10 | 11 | 12 | def get_terminal_size(fd): 13 | data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00\x00\00\x00") 14 | rows, cols = struct.unpack("hh", data) 15 | return rows, cols 16 | 17 | 18 | def set_terminal_size(fd, rows, cols): 19 | xpix = ypix = 0 20 | winsize = struct.pack("HHHH", rows, cols, xpix, ypix) 21 | fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) 22 | 23 | 24 | def copy_terminal_dimensions(src_fd, dest_fd): 25 | rows, cols = get_terminal_size(src_fd) 26 | set_terminal_size(dest_fd, rows, cols) 27 | 28 | 29 | @contextmanager 30 | def make_raw(fd): 31 | mode = None 32 | try: 33 | mode = tty.tcgetattr(fd) 34 | tty.setraw(fd) 35 | yield 36 | except tty.error: 37 | pass 38 | except Exception as e: 39 | print(e) 40 | if mode: 41 | tty.tcsetattr(fd, tty.TCSAFLUSH, mode) 42 | 43 | 44 | def get_random_string(n): 45 | return "".join(random.choices(string.ascii_letters + string.digits, k=n)) 46 | -------------------------------------------------------------------------------- /termpair_browser.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cs01/termpair/543d84af21e2892a8b6f177d1c397b88973b0e98/termpair_browser.gif -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import socket 3 | import subprocess 4 | import sys 5 | import tty 6 | from os import getenv 7 | from time import sleep 8 | 9 | import psutil # type: ignore 10 | import pytest # type:ignore 11 | 12 | 13 | def get_open_port() -> int: 14 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 15 | s.bind(("", 0)) 16 | s.listen(1) 17 | port = s.getsockname()[1] 18 | s.close() 19 | return port 20 | 21 | 22 | def test_version_parsing(): 23 | subprocess.run(["termpair", "--version"], check=True) 24 | 25 | 26 | def test_server(): 27 | open_port = str(get_open_port()) 28 | server = subprocess.Popen(["termpair", "serve", "--port", open_port]) 29 | sleep(0.1) 30 | assert server.poll() is None 31 | server.kill() 32 | 33 | 34 | def test_e2e(): 35 | if getenv("CI") is not None: 36 | pytest.skip( 37 | "On CI we get: termios.error: (25, 'Inappropriate ioctl for device')" 38 | ) 39 | 40 | sys.stdin.fileno() 41 | mode = tty.tcgetattr(sys.stdin.fileno()) 42 | try: 43 | open_port = str(get_open_port()) 44 | server = subprocess.Popen( 45 | ["termpair", "serve", "--port", open_port], 46 | stdout=subprocess.PIPE, 47 | stderr=subprocess.PIPE, 48 | ) 49 | sleep(0.5) 50 | broadcast = subprocess.Popen( 51 | ["termpair", "share", "--cmd", "bash", "--port", open_port], 52 | stdout=subprocess.PIPE, 53 | stderr=subprocess.PIPE, 54 | ) 55 | sleep(0.5) 56 | assert server.poll() is None 57 | assert broadcast.poll() is None 58 | server.kill() 59 | kill_child_processes(broadcast.pid) 60 | broadcast.kill() 61 | tty.setcbreak(sys.stdin.fileno()) 62 | tty.tcsetattr(sys.stdin.fileno(), tty.TCSAFLUSH, mode) 63 | server_output = server.stderr.read().decode() 64 | 65 | assert "Started server process" in server_output 66 | assert '- "WebSocket /connect_to_terminal" [accepted]' in server_output 67 | 68 | broadcast_output = broadcast.stdout.read().decode() 69 | assert "Type 'exit' or close terminal to stop sharing." in broadcast_output 70 | finally: 71 | tty.setcbreak(sys.stdin.fileno()) 72 | tty.tcsetattr(sys.stdin.fileno(), tty.TCSAFLUSH, mode) 73 | 74 | 75 | def kill_child_processes(parent_pid, sig=signal.SIGTERM): 76 | try: 77 | parent = psutil.Process(parent_pid) 78 | except psutil.NoSuchProcess: 79 | return 80 | children = parent.children() 81 | for process in children: 82 | process.send_signal(sig) 83 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient # type:ignore 2 | from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware # type:ignore 3 | 4 | from termpair import server 5 | 6 | client = TestClient(server.app) 7 | 8 | 9 | def test_read_main(): 10 | response = client.get("/") 11 | assert response.status_code == 200 12 | 13 | 14 | def test_terminal_data(): 15 | response = client.get("/terminal/invalid-terminal-id") 16 | assert response.status_code == 404 17 | 18 | response = client.get("/terminal/") 19 | assert response.status_code == 404 20 | 21 | 22 | def test_can_add_middleware(): 23 | server.app.add_middleware(HTTPSRedirectMiddleware) 24 | --------------------------------------------------------------------------------