├── .dockerignore ├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .prettierrc ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── Dockerfile ├── LICENSE ├── README.md ├── compose.yaml ├── crates ├── sshx-core │ ├── Cargo.toml │ ├── build.rs │ ├── proto │ │ └── sshx.proto │ └── src │ │ └── lib.rs ├── sshx-server │ ├── Cargo.toml │ ├── src │ │ ├── grpc.rs │ │ ├── lib.rs │ │ ├── listen.rs │ │ ├── main.rs │ │ ├── session.rs │ │ ├── session │ │ │ └── snapshot.rs │ │ ├── state.rs │ │ ├── state │ │ │ └── mesh.rs │ │ ├── utils.rs │ │ ├── web.rs │ │ └── web │ │ │ ├── protocol.rs │ │ │ └── socket.rs │ └── tests │ │ ├── common │ │ └── mod.rs │ │ ├── simple.rs │ │ ├── snapshot.rs │ │ └── with_client.rs └── sshx │ ├── Cargo.toml │ ├── examples │ └── stdin_client.rs │ └── src │ ├── controller.rs │ ├── encrypt.rs │ ├── lib.rs │ ├── main.rs │ ├── runner.rs │ ├── terminal.rs │ └── terminal │ ├── unix.rs │ └── windows.rs ├── fly.toml ├── mprocs.yaml ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── rustfmt.toml ├── scripts └── release.sh ├── src ├── app.css ├── app.d.ts ├── app.html ├── lib │ ├── Session.svelte │ ├── action │ │ ├── slide.ts │ │ └── touchZoom.ts │ ├── arrange.ts │ ├── assets │ │ ├── landing-background.svg │ │ ├── landing-graphic.svg │ │ ├── logo.svg │ │ └── logotype-dark.svg │ ├── encrypt.ts │ ├── lock.ts │ ├── protocol.ts │ ├── settings.ts │ ├── srocket.ts │ ├── toast.ts │ ├── typeahead.ts │ └── ui │ │ ├── Avatars.svelte │ │ ├── Chat.svelte │ │ ├── ChooseName.svelte │ │ ├── CircleButton.svelte │ │ ├── CircleButtons.svelte │ │ ├── CopyableCode.svelte │ │ ├── DownloadLink.svelte │ │ ├── LiveCursor.svelte │ │ ├── NameList.svelte │ │ ├── NetworkInfo.svelte │ │ ├── OverlayMenu.svelte │ │ ├── Settings.svelte │ │ ├── TeaserVideo.svelte │ │ ├── Toast.svelte │ │ ├── ToastContainer.svelte │ │ ├── Toolbar.svelte │ │ ├── XTerm.svelte │ │ └── themes.ts └── routes │ ├── +error.svelte │ ├── +layout.svelte │ ├── +page.svelte │ ├── +page.ts │ └── s │ └── [id] │ └── +page.svelte ├── static ├── favicon.svg ├── get └── images │ └── social-image2.png ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | trim_trailing_whitespace = true 5 | 6 | [*.rs] 7 | tab_width = 4 8 | 9 | [*.{js,jsx,ts,tsx,html,css,svelte,proto}] 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier", 8 | ], 9 | plugins: ["svelte3", "@typescript-eslint"], 10 | ignorePatterns: ["*.cjs"], 11 | overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], 12 | settings: { 13 | "svelte3/typescript": () => require("typescript"), 14 | }, 15 | rules: { 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "@typescript-eslint/ban-types": "off", 18 | "@typescript-eslint/no-empty-function": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/no-inferrable-types": "off", 21 | "@typescript-eslint/no-non-null-assertion": "off", 22 | "no-constant-condition": "off", 23 | "no-control-regex": "off", 24 | "no-empty": "off", 25 | "no-undef": "off", 26 | }, 27 | parserOptions: { 28 | sourceType: "module", 29 | ecmaVersion: 2020, 30 | }, 31 | env: { 32 | browser: true, 33 | es2017: true, 34 | node: true, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | rustfmt: 13 | name: Rust format 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - run: rustup toolchain install nightly --profile minimal -c rustfmt 20 | 21 | - run: cargo +nightly fmt -- --check 22 | 23 | rust: 24 | name: Rust lint and test 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: arduino/setup-protoc@v2 31 | 32 | - run: rustup toolchain install stable 33 | 34 | - uses: Swatinem/rust-cache@v2 35 | 36 | - run: cargo test 37 | 38 | - run: cargo clippy --all-targets -- -D warnings 39 | 40 | windows_test: 41 | name: Client test (Windows) 42 | runs-on: windows-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: arduino/setup-protoc@v2 48 | 49 | - run: rustup toolchain install stable 50 | 51 | - uses: Swatinem/rust-cache@v2 52 | 53 | - run: cargo test -p sshx 54 | 55 | web: 56 | name: Web lint, check, and build 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: "18" 65 | 66 | - run: npm ci 67 | 68 | - run: npm run lint 69 | 70 | - run: npm run check 71 | 72 | - run: npm run build 73 | 74 | deploy: 75 | name: Deploy 76 | runs-on: ubuntu-latest 77 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 78 | needs: [rustfmt, rust, web] 79 | concurrency: 80 | group: deploy 81 | cancel-in-progress: true 82 | 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - uses: superfly/flyctl-actions/setup-flyctl@v1 87 | 88 | - run: flyctl deploy 89 | env: 90 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | 3 | /target 4 | 5 | /node_modules 6 | /.svelte-kit 7 | /build 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.4.1" 7 | authors = ["Eric Zhang "] 8 | license = "MIT" 9 | description = "A secure web-based, collaborative terminal." 10 | repository = "https://github.com/ekzhang/sshx" 11 | documentation = "https://sshx.io" 12 | keywords = ["ssh", "share", "terminal", "collaborative"] 13 | 14 | [workspace.dependencies] 15 | anyhow = "1.0.62" 16 | clap = { version = "4.5.17", features = ["derive", "env"] } 17 | prost = "0.13.4" 18 | rand = "0.8.5" 19 | serde = { version = "1.0.188", features = ["derive", "rc"] } 20 | sshx-core = { version = "0.4.1", path = "crates/sshx-core" } 21 | tokio = { version = "1.40.0", features = ["full"] } 22 | tokio-stream = { version = "0.1.14", features = ["sync"] } 23 | tonic = { version = "0.12.3", features = ["tls", "tls-webpki-roots"] } 24 | tonic-build = "0.12.3" 25 | tonic-reflection = "0.12.3" 26 | tracing = "0.1.37" 27 | tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } 28 | 29 | [profile.release] 30 | strip = true 31 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-freebsd] 2 | pre-build = [ 3 | "apt-get update", 4 | 5 | # Protobuf version is too outdated on the cargo-cross image, which is ubuntu:20.04. 6 | # "apt install -y protobuf-compiler", 7 | 8 | "apt install -y wget libarchive-tools", 9 | "mkdir /protoc", 10 | "wget -qO- https://github.com/protocolbuffers/protobuf/releases/download/v29.2/protoc-29.2-linux-x86_64.zip | bsdtar -xvf- -C /protoc", 11 | "mv -v /protoc/bin/protoc /usr/local/bin && chmod +x /usr/local/bin/protoc", 12 | "mkdir -p /usr/local/include/google/protobuf/", 13 | "mv -v /protoc/include/google/protobuf/* /usr/local/include/google/protobuf/", 14 | "rm -rf /protoc", 15 | ] 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:alpine AS backend 2 | WORKDIR /home/rust/src 3 | RUN apk --no-cache add musl-dev openssl-dev protoc 4 | RUN rustup component add rustfmt 5 | COPY . . 6 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 7 | --mount=type=cache,target=/home/rust/src/target \ 8 | cargo build --release --bin sshx-server && \ 9 | cp target/release/sshx-server /usr/local/bin 10 | 11 | FROM node:lts-alpine AS frontend 12 | RUN apk --no-cache add git 13 | WORKDIR /usr/src/app 14 | COPY . . 15 | RUN npm ci 16 | RUN npm run build 17 | 18 | FROM alpine:latest 19 | WORKDIR /root 20 | COPY --from=frontend /usr/src/app/build build 21 | COPY --from=backend /usr/local/bin/sshx-server . 22 | CMD ["./sshx-server", "--listen", "::"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eric Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshx 2 | 3 | A secure web-based, collaborative terminal. 4 | 5 | ![](https://i.imgur.com/Q3qKAHW.png) 6 | 7 | **Features:** 8 | 9 | - Run a single command to share your terminal with anyone. 10 | - Resize, move windows, and freely zoom and pan on an infinite canvas. 11 | - See other people's cursors moving in real time. 12 | - Connect to the nearest server in a globally distributed mesh. 13 | - End-to-end encryption with Argon2 and AES. 14 | - Automatic reconnection and real-time latency estimates. 15 | - Predictive echo for faster local editing (à la Mosh). 16 | 17 | Visit [sshx.io](https://sshx.io) to learn more. 18 | 19 | ## Installation 20 | 21 | Just run this command to get the `sshx` binary for your platform. 22 | 23 | ```shell 24 | curl -sSf https://sshx.io/get | sh 25 | ``` 26 | 27 | Supports Linux and MacOS on x86_64 and ARM64 architectures, as well as embedded 28 | ARMv6 and ARMv7-A systems. The Linux binaries are statically linked. 29 | 30 | For Windows, there are binaries for x86_64, x86, and ARM64, linked to MSVC for 31 | maximum compatibility. 32 | 33 | If you just want to try it out without installing, use: 34 | 35 | ```shell 36 | curl -sSf https://sshx.io/get | sh -s run 37 | ``` 38 | 39 | Inspect the script for additional options. 40 | 41 | You can also install it with [Homebrew](https://brew.sh/) on macOS. 42 | 43 | ```shell 44 | brew install sshx 45 | ``` 46 | 47 | ### CI/CD 48 | 49 | You can run sshx in continuous integration workflows to help debug tricky 50 | issues, like in GitHub Actions. 51 | 52 | ```yaml 53 | name: CI 54 | on: push 55 | 56 | jobs: 57 | build: 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v3 61 | 62 | # ... other steps ... 63 | 64 | - run: curl -sSf https://sshx.io/get | sh -s run 65 | # ^ 66 | # └ This will open a remote terminal session and print the URL. It 67 | # should take under a second. 68 | ``` 69 | 70 | We don't have a prepackaged action because it's just a single command. It works 71 | anywhere: GitLab CI, CircleCI, Buildkite, CI on your Raspberry Pi, etc. 72 | 73 | Be careful adding this to a public GitHub repository, as any user can view the 74 | logs of a CI job while it is running. 75 | 76 | ## Development 77 | 78 | Here's how to work on the project, if you want to contribute. 79 | 80 | ### Building from source 81 | 82 | To build the latest version of the client from source, clone this repository and 83 | run, with [Rust](https://rust-lang.com/) installed: 84 | 85 | ```shell 86 | cargo install --path crates/sshx 87 | ``` 88 | 89 | This will compile the `sshx` binary and place it in your `~/.cargo/bin` folder. 90 | 91 | ### Workflow 92 | 93 | First, start service containers for development. 94 | 95 | ```shell 96 | docker compose up -d 97 | ``` 98 | 99 | Install [Rust 1.70+](https://www.rust-lang.org/), 100 | [Node v18](https://nodejs.org/), [NPM v9](https://www.npmjs.com/), and 101 | [mprocs](https://github.com/pvolok/mprocs). Then, run 102 | 103 | ```shell 104 | npm install 105 | mprocs 106 | ``` 107 | 108 | This will compile and start the server, an instance of the client, and the web 109 | frontend in parallel on your machine. 110 | 111 | ## Deployment 112 | 113 | I host the application servers on [Fly.io](https://fly.io/) and with 114 | [Redis Cloud](https://redis.com/). 115 | 116 | Self-hosted deployments are not supported at the moment. If you want to deploy 117 | sshx, you'll need to properly implement HTTP/TCP reverse proxies, gRPC 118 | forwarding, TLS termination, private mesh networking, and graceful shutdown. 119 | 120 | Please do not run the development commands in a public setting, as this is 121 | insecure. 122 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # Services used by sshx for development. These listen on ports 126XX, to reduce the chance that they 2 | # conflict with other processes. 3 | # 4 | # You can start them with `docker compose up -d`. 5 | 6 | services: 7 | redis: 8 | image: bitnami/redis:7.2 9 | environment: 10 | - ALLOW_EMPTY_PASSWORD=yes 11 | ports: 12 | - 127.0.0.1:12601:6379 13 | -------------------------------------------------------------------------------- /crates/sshx-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sshx-core" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | description.workspace = true 7 | repository.workspace = true 8 | documentation.workspace = true 9 | keywords.workspace = true 10 | edition = "2021" 11 | 12 | [dependencies] 13 | prost.workspace = true 14 | rand.workspace = true 15 | serde.workspace = true 16 | tonic.workspace = true 17 | 18 | [build-dependencies] 19 | tonic-build.workspace = true 20 | -------------------------------------------------------------------------------- /crates/sshx-core/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf}; 2 | 3 | fn main() -> Result<(), Box> { 4 | let descriptor_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("sshx.bin"); 5 | tonic_build::configure() 6 | .file_descriptor_set_path(descriptor_path) 7 | .bytes(["."]) 8 | .compile_protos(&["proto/sshx.proto"], &["proto/"])?; 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /crates/sshx-core/proto/sshx.proto: -------------------------------------------------------------------------------- 1 | // This file contains the service definition for sshx, used by the client to 2 | // communicate their terminal state over gRPC. 3 | 4 | syntax = "proto3"; 5 | package sshx; 6 | 7 | service SshxService { 8 | // Create a new SSH session for a given computer. 9 | rpc Open(OpenRequest) returns (OpenResponse); 10 | 11 | // Stream real-time commands and terminal outputs to the session. 12 | rpc Channel(stream ClientUpdate) returns (stream ServerUpdate); 13 | 14 | // Gracefully shut down an existing SSH session. 15 | rpc Close(CloseRequest) returns (CloseResponse); 16 | } 17 | 18 | // Details of bytes exchanged with the terminal. 19 | message TerminalData { 20 | uint32 id = 1; // ID of the shell. 21 | bytes data = 2; // Encrypted, UTF-8 terminal data. 22 | uint64 seq = 3; // Sequence number of the first byte. 23 | } 24 | 25 | // Details of bytes input to the terminal (not necessarily valid UTF-8). 26 | message TerminalInput { 27 | uint32 id = 1; // ID of the shell. 28 | bytes data = 2; // Encrypted binary sequence of terminal data. 29 | uint64 offset = 3; // Offset of the first byte for encryption. 30 | } 31 | 32 | // Pair of a terminal ID and its associated size. 33 | message TerminalSize { 34 | uint32 id = 1; // ID of the shell. 35 | uint32 rows = 2; // Number of rows for the terminal. 36 | uint32 cols = 3; // Number of columns for the terminal. 37 | } 38 | 39 | // Request to open an sshx session. 40 | message OpenRequest { 41 | string origin = 1; // Web origin of the server. 42 | bytes encrypted_zeros = 2; // Encrypted zero block, for client verification. 43 | string name = 3; // Name of the session (user@hostname). 44 | optional bytes write_password_hash = 4; // Hashed write password, if read-only mode is enabled. 45 | } 46 | 47 | // Details of a newly-created sshx session. 48 | message OpenResponse { 49 | string name = 1; // Name of the session. 50 | string token = 2; // Signed verification token for the client. 51 | string url = 3; // Public web URL to view the session. 52 | } 53 | 54 | // Sequence numbers for all active shells, used for synchronization. 55 | message SequenceNumbers { 56 | map map = 1; // Active shells and their sequence numbers. 57 | } 58 | 59 | // Data for a new shell. 60 | message NewShell { 61 | uint32 id = 1; // ID of the shell. 62 | int32 x = 2; // X position of the shell. 63 | int32 y = 3; // Y position of the shell. 64 | } 65 | 66 | // Bidirectional streaming update from the client. 67 | message ClientUpdate { 68 | oneof client_message { 69 | string hello = 1; // First stream message: "name,token". 70 | TerminalData data = 2; // Stream data from the terminal. 71 | NewShell created_shell = 3; // Acknowledge that a new shell was created. 72 | uint32 closed_shell = 4; // Acknowledge that a shell was closed. 73 | fixed64 pong = 14; // Response for latency measurement. 74 | string error = 15; 75 | } 76 | } 77 | 78 | // Bidirectional streaming update from the server. 79 | message ServerUpdate { 80 | oneof server_message { 81 | TerminalInput input = 1; // Remote input bytes, received from the user. 82 | NewShell create_shell = 2; // ID of a new shell. 83 | uint32 close_shell = 3; // ID of a shell to close. 84 | SequenceNumbers sync = 4; // Periodic sequence number sync. 85 | TerminalSize resize = 5; // Resize a terminal window. 86 | fixed64 ping = 14; // Request a pong, with the timestamp. 87 | string error = 15; 88 | } 89 | } 90 | 91 | // Request to stop a sshx session gracefully. 92 | message CloseRequest { 93 | string name = 1; // Name of the session to terminate. 94 | string token = 2; // Session verification token. 95 | } 96 | 97 | // Server response to closing a session. 98 | message CloseResponse {} 99 | 100 | // Snapshot of a session, used to restore state for persistence across servers. 101 | message SerializedSession { 102 | bytes encrypted_zeros = 1; 103 | map shells = 2; 104 | uint32 next_sid = 3; 105 | uint32 next_uid = 4; 106 | string name = 5; 107 | optional bytes write_password_hash = 6; 108 | } 109 | 110 | message SerializedShell { 111 | uint64 seqnum = 1; 112 | repeated bytes data = 2; 113 | uint64 chunk_offset = 3; 114 | uint64 byte_offset = 4; 115 | bool closed = 5; 116 | int32 winsize_x = 6; 117 | int32 winsize_y = 7; 118 | uint32 winsize_rows = 8; 119 | uint32 winsize_cols = 9; 120 | } 121 | -------------------------------------------------------------------------------- /crates/sshx-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The core crate for shared code used in the sshx application. 2 | 3 | #![forbid(unsafe_code)] 4 | #![warn(missing_docs)] 5 | 6 | use std::fmt::Display; 7 | use std::sync::atomic::{AtomicU32, Ordering}; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | 11 | /// Protocol buffer and gRPC definitions, automatically generated by Tonic. 12 | #[allow(missing_docs, non_snake_case)] 13 | #[allow(clippy::derive_partial_eq_without_eq)] 14 | pub mod proto { 15 | tonic::include_proto!("sshx"); 16 | 17 | /// File descriptor set used for gRPC reflection. 18 | pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("sshx"); 19 | } 20 | 21 | /// Generate a cryptographically-secure, random alphanumeric value. 22 | pub fn rand_alphanumeric(len: usize) -> String { 23 | use rand::{distributions::Alphanumeric, thread_rng, Rng}; 24 | thread_rng() 25 | .sample_iter(Alphanumeric) 26 | .take(len) 27 | .map(char::from) 28 | .collect() 29 | } 30 | 31 | /// Unique identifier for a shell within the session. 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 33 | #[serde(transparent)] 34 | pub struct Sid(pub u32); 35 | 36 | impl Display for Sid { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 38 | write!(f, "{}", self.0) 39 | } 40 | } 41 | 42 | /// Unique identifier for a user within the session. 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 44 | #[serde(transparent)] 45 | pub struct Uid(pub u32); 46 | 47 | impl Display for Uid { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | write!(f, "{}", self.0) 50 | } 51 | } 52 | 53 | /// A counter for generating unique identifiers. 54 | #[derive(Debug)] 55 | pub struct IdCounter { 56 | next_sid: AtomicU32, 57 | next_uid: AtomicU32, 58 | } 59 | 60 | impl Default for IdCounter { 61 | fn default() -> Self { 62 | Self { 63 | next_sid: AtomicU32::new(1), 64 | next_uid: AtomicU32::new(1), 65 | } 66 | } 67 | } 68 | 69 | impl IdCounter { 70 | /// Returns the next unique shell ID. 71 | pub fn next_sid(&self) -> Sid { 72 | Sid(self.next_sid.fetch_add(1, Ordering::Relaxed)) 73 | } 74 | 75 | /// Returns the next unique user ID. 76 | pub fn next_uid(&self) -> Uid { 77 | Uid(self.next_uid.fetch_add(1, Ordering::Relaxed)) 78 | } 79 | 80 | /// Return the current internal values of the counter. 81 | pub fn get_current_values(&self) -> (Sid, Uid) { 82 | ( 83 | Sid(self.next_sid.load(Ordering::Relaxed)), 84 | Uid(self.next_uid.load(Ordering::Relaxed)), 85 | ) 86 | } 87 | 88 | /// Set the internal values of the counter. 89 | pub fn set_current_values(&self, sid: Sid, uid: Uid) { 90 | self.next_sid.store(sid.0, Ordering::Relaxed); 91 | self.next_uid.store(uid.0, Ordering::Relaxed); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/sshx-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sshx-server" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | description.workspace = true 7 | repository.workspace = true 8 | documentation.workspace = true 9 | keywords.workspace = true 10 | edition = "2021" 11 | 12 | [dependencies] 13 | anyhow.workspace = true 14 | async-channel = "1.9.0" 15 | async-stream = "0.3.5" 16 | axum = { version = "0.8.1", features = ["http2", "ws"] } 17 | base64 = "0.21.4" 18 | bytes = { version = "1.5.0", features = ["serde"] } 19 | ciborium = "0.2.1" 20 | clap.workspace = true 21 | dashmap = "5.5.3" 22 | deadpool = "0.12.2" 23 | deadpool-redis = "0.18.0" 24 | futures-util = { version = "0.3.28", features = ["sink"] } 25 | hmac = "0.12.1" 26 | http = "1.2.0" 27 | parking_lot = "0.12.1" 28 | prost.workspace = true 29 | rand.workspace = true 30 | redis = { version = "0.27.6", features = ["tokio-rustls-comp", "tls-rustls-webpki-roots"] } 31 | serde.workspace = true 32 | sha2 = "0.10.7" 33 | sshx-core.workspace = true 34 | subtle = "2.5.0" 35 | tokio.workspace = true 36 | tokio-stream.workspace = true 37 | tokio-tungstenite = "0.26.1" 38 | tonic.workspace = true 39 | tonic-reflection.workspace = true 40 | tower = { version = "0.4.13", features = ["steer"] } 41 | tower-http = { version = "0.6.2", features = ["fs", "redirect", "trace"] } 42 | tracing.workspace = true 43 | tracing-subscriber.workspace = true 44 | zstd = "0.12.4" 45 | 46 | [dev-dependencies] 47 | reqwest = { version = "0.12.12", default-features = false, features = ["rustls-tls"] } 48 | sshx = { path = "../sshx" } 49 | -------------------------------------------------------------------------------- /crates/sshx-server/src/grpc.rs: -------------------------------------------------------------------------------- 1 | //! Defines gRPC routes and application request logic. 2 | 3 | use std::sync::Arc; 4 | use std::time::{Duration, SystemTime}; 5 | 6 | use base64::prelude::{Engine as _, BASE64_STANDARD}; 7 | use hmac::Mac; 8 | use sshx_core::proto::{ 9 | client_update::ClientMessage, server_update::ServerMessage, sshx_service_server::SshxService, 10 | ClientUpdate, CloseRequest, CloseResponse, OpenRequest, OpenResponse, ServerUpdate, 11 | }; 12 | use sshx_core::{rand_alphanumeric, Sid}; 13 | use tokio::sync::mpsc; 14 | use tokio::time::{self, MissedTickBehavior}; 15 | use tokio_stream::{wrappers::ReceiverStream, StreamExt}; 16 | use tonic::{Request, Response, Status, Streaming}; 17 | use tracing::{error, info, warn}; 18 | 19 | use crate::session::{Metadata, Session}; 20 | use crate::ServerState; 21 | 22 | /// Interval for synchronizing sequence numbers with the client. 23 | pub const SYNC_INTERVAL: Duration = Duration::from_secs(5); 24 | 25 | /// Interval for measuring client latency. 26 | pub const PING_INTERVAL: Duration = Duration::from_secs(2); 27 | 28 | /// Server that handles gRPC requests from the sshx command-line client. 29 | #[derive(Clone)] 30 | pub struct GrpcServer(Arc); 31 | 32 | impl GrpcServer { 33 | /// Construct a new [`GrpcServer`] instance with associated state. 34 | pub fn new(state: Arc) -> Self { 35 | Self(state) 36 | } 37 | } 38 | 39 | type RR = Result, Status>; 40 | 41 | #[tonic::async_trait] 42 | impl SshxService for GrpcServer { 43 | type ChannelStream = ReceiverStream>; 44 | 45 | async fn open(&self, request: Request) -> RR { 46 | let request = request.into_inner(); 47 | let origin = self.0.override_origin().unwrap_or(request.origin); 48 | if origin.is_empty() { 49 | return Err(Status::invalid_argument("origin is empty")); 50 | } 51 | let name = rand_alphanumeric(10); 52 | info!(%name, "creating new session"); 53 | 54 | match self.0.lookup(&name) { 55 | Some(_) => return Err(Status::already_exists("generated duplicate ID")), 56 | None => { 57 | let metadata = Metadata { 58 | encrypted_zeros: request.encrypted_zeros, 59 | name: request.name, 60 | write_password_hash: request.write_password_hash, 61 | }; 62 | self.0.insert(&name, Arc::new(Session::new(metadata))); 63 | } 64 | }; 65 | let token = self.0.mac().chain_update(&name).finalize(); 66 | let url = format!("{origin}/s/{name}"); 67 | Ok(Response::new(OpenResponse { 68 | name, 69 | token: BASE64_STANDARD.encode(token.into_bytes()), 70 | url, 71 | })) 72 | } 73 | 74 | async fn channel(&self, request: Request>) -> RR { 75 | let mut stream = request.into_inner(); 76 | let first_update = match stream.next().await { 77 | Some(result) => result?, 78 | None => return Err(Status::invalid_argument("missing first message")), 79 | }; 80 | let session_name = match first_update.client_message { 81 | Some(ClientMessage::Hello(hello)) => { 82 | let (name, token) = hello 83 | .split_once(',') 84 | .ok_or_else(|| Status::invalid_argument("missing name and token"))?; 85 | validate_token(self.0.mac(), name, token)?; 86 | name.to_string() 87 | } 88 | _ => return Err(Status::invalid_argument("invalid first message")), 89 | }; 90 | let session = match self.0.backend_connect(&session_name).await { 91 | Ok(Some(session)) => session, 92 | Ok(None) => return Err(Status::not_found("session not found")), 93 | Err(err) => { 94 | error!(?err, "failed to connect to backend session"); 95 | return Err(Status::internal(err.to_string())); 96 | } 97 | }; 98 | 99 | // We now spawn an asynchronous task that sends updates to the client. Note that 100 | // when this task finishes, the sender end is dropped, so the receiver is 101 | // automatically closed. 102 | let (tx, rx) = mpsc::channel(16); 103 | tokio::spawn(async move { 104 | if let Err(err) = handle_streaming(&tx, &session, stream).await { 105 | warn!(?err, "connection exiting early due to an error"); 106 | } 107 | }); 108 | 109 | Ok(Response::new(ReceiverStream::new(rx))) 110 | } 111 | 112 | async fn close(&self, request: Request) -> RR { 113 | let request = request.into_inner(); 114 | validate_token(self.0.mac(), &request.name, &request.token)?; 115 | info!("closing session {}", request.name); 116 | if let Err(err) = self.0.close_session(&request.name).await { 117 | error!(?err, "failed to close session {}", request.name); 118 | return Err(Status::internal(err.to_string())); 119 | } 120 | Ok(Response::new(CloseResponse {})) 121 | } 122 | } 123 | 124 | /// Validate the client token for a session. 125 | fn validate_token(mac: impl Mac, name: &str, token: &str) -> Result<(), Status> { 126 | if let Ok(token) = BASE64_STANDARD.decode(token) { 127 | if mac.chain_update(name).verify_slice(&token).is_ok() { 128 | return Ok(()); 129 | } 130 | } 131 | Err(Status::unauthenticated("invalid token")) 132 | } 133 | 134 | type ServerTx = mpsc::Sender>; 135 | 136 | /// Handle bidirectional streaming messages RPC messages. 137 | async fn handle_streaming( 138 | tx: &ServerTx, 139 | session: &Session, 140 | mut stream: Streaming, 141 | ) -> Result<(), &'static str> { 142 | let mut sync_interval = time::interval(SYNC_INTERVAL); 143 | sync_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); 144 | 145 | let mut ping_interval = time::interval(PING_INTERVAL); 146 | ping_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); 147 | 148 | loop { 149 | tokio::select! { 150 | // Send periodic sync messages to the client. 151 | _ = sync_interval.tick() => { 152 | let msg = ServerMessage::Sync(session.sequence_numbers()); 153 | if !send_msg(tx, msg).await { 154 | return Err("failed to send sync message"); 155 | } 156 | } 157 | // Send periodic pings to the client. 158 | _ = ping_interval.tick() => { 159 | send_msg(tx, ServerMessage::Ping(get_time_ms())).await; 160 | } 161 | // Send buffered server updates to the client. 162 | Ok(msg) = session.update_rx().recv() => { 163 | if !send_msg(tx, msg).await { 164 | return Err("failed to send update message"); 165 | } 166 | } 167 | // Handle incoming client messages. 168 | maybe_update = stream.next() => { 169 | if let Some(Ok(update)) = maybe_update { 170 | if !handle_update(tx, session, update).await { 171 | return Err("error responding to client update"); 172 | } 173 | } else { 174 | // The client has hung up on their end. 175 | return Ok(()); 176 | } 177 | } 178 | // Exit on a session shutdown signal. 179 | _ = session.terminated() => { 180 | let msg = String::from("disconnecting because session is closed"); 181 | send_msg(tx, ServerMessage::Error(msg)).await; 182 | return Ok(()); 183 | } 184 | }; 185 | } 186 | } 187 | 188 | /// Handles a singe update from the client. Returns `true` on success. 189 | async fn handle_update(tx: &ServerTx, session: &Session, update: ClientUpdate) -> bool { 190 | session.access(); 191 | match update.client_message { 192 | Some(ClientMessage::Hello(_)) => { 193 | return send_err(tx, "unexpected hello".into()).await; 194 | } 195 | Some(ClientMessage::Data(data)) => { 196 | if let Err(err) = session.add_data(Sid(data.id), data.data, data.seq) { 197 | return send_err(tx, format!("add data: {:?}", err)).await; 198 | } 199 | } 200 | Some(ClientMessage::CreatedShell(new_shell)) => { 201 | let id = Sid(new_shell.id); 202 | let center = (new_shell.x, new_shell.y); 203 | if let Err(err) = session.add_shell(id, center) { 204 | return send_err(tx, format!("add shell: {:?}", err)).await; 205 | } 206 | } 207 | Some(ClientMessage::ClosedShell(id)) => { 208 | if let Err(err) = session.close_shell(Sid(id)) { 209 | return send_err(tx, format!("close shell: {:?}", err)).await; 210 | } 211 | } 212 | Some(ClientMessage::Pong(ts)) => { 213 | let latency = get_time_ms().saturating_sub(ts); 214 | session.send_latency_measurement(latency); 215 | } 216 | Some(ClientMessage::Error(err)) => { 217 | // TODO: Propagate these errors to listeners on the web interface? 218 | error!(?err, "error received from client"); 219 | } 220 | None => (), // Heartbeat message, ignored. 221 | } 222 | true 223 | } 224 | 225 | /// Attempt to send a server message to the client. 226 | async fn send_msg(tx: &ServerTx, message: ServerMessage) -> bool { 227 | let update = Ok(ServerUpdate { 228 | server_message: Some(message), 229 | }); 230 | tx.send(update).await.is_ok() 231 | } 232 | 233 | /// Attempt to send an error string to the client. 234 | async fn send_err(tx: &ServerTx, err: String) -> bool { 235 | send_msg(tx, ServerMessage::Error(err)).await 236 | } 237 | 238 | fn get_time_ms() -> u64 { 239 | SystemTime::now() 240 | .duration_since(SystemTime::UNIX_EPOCH) 241 | .expect("system time is before the UNIX epoch") 242 | .as_millis() as u64 243 | } 244 | -------------------------------------------------------------------------------- /crates/sshx-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The sshx server, which coordinates terminal sharing. 2 | //! 3 | //! Requests are communicated to the server via gRPC (for command-line sharing 4 | //! clients) and WebSocket connections (for web listeners). The server is built 5 | //! using a hybrid Hyper service, split between a Tonic gRPC handler and an Axum 6 | //! web listener. 7 | //! 8 | //! Most web requests are routed directly to static files located in the 9 | //! `build/` folder relative to where this binary is running, allowing the 10 | //! frontend to be separately developed from the server. 11 | 12 | #![forbid(unsafe_code)] 13 | #![warn(missing_docs)] 14 | 15 | use std::{fmt::Debug, net::SocketAddr, sync::Arc}; 16 | 17 | use anyhow::Result; 18 | use axum::serve::{Listener, ListenerExt}; 19 | use tokio::net::TcpListener; 20 | use tracing::debug; 21 | use utils::Shutdown; 22 | 23 | use crate::state::ServerState; 24 | 25 | pub mod grpc; 26 | mod listen; 27 | pub mod session; 28 | pub mod state; 29 | pub mod utils; 30 | pub mod web; 31 | 32 | /// Options when constructing the application server. 33 | #[derive(Clone, Debug, Default)] 34 | #[non_exhaustive] 35 | pub struct ServerOptions { 36 | /// Secret used for signing tokens. Set randomly if not provided. 37 | pub secret: Option, 38 | 39 | /// Override the origin returned for the Open() RPC. 40 | pub override_origin: Option, 41 | 42 | /// URL of the Redis server that stores session data. 43 | pub redis_url: Option, 44 | 45 | /// Hostname of this server, if running multiple servers. 46 | pub host: Option, 47 | } 48 | 49 | /// Stateful object that manages the sshx server, with graceful termination. 50 | pub struct Server { 51 | state: Arc, 52 | shutdown: Shutdown, 53 | } 54 | 55 | impl Server { 56 | /// Create a new application server, but do not listen for connections yet. 57 | pub fn new(options: ServerOptions) -> Result { 58 | Ok(Self { 59 | state: Arc::new(ServerState::new(options)?), 60 | shutdown: Shutdown::new(), 61 | }) 62 | } 63 | 64 | /// Returns the server's state object. 65 | pub fn state(&self) -> Arc { 66 | Arc::clone(&self.state) 67 | } 68 | 69 | /// Run the application server, listening on a stream of connections. 70 | pub async fn listen(&self, listener: L) -> Result<()> 71 | where 72 | L: Listener, 73 | L::Addr: Debug, 74 | { 75 | let state = self.state.clone(); 76 | let terminated = self.shutdown.wait(); 77 | tokio::spawn(async move { 78 | let background_tasks = futures_util::future::join( 79 | state.listen_for_transfers(), 80 | state.close_old_sessions(), 81 | ); 82 | tokio::select! { 83 | _ = terminated => {} 84 | _ = background_tasks => {} 85 | } 86 | }); 87 | 88 | listen::start_server(self.state(), listener, self.shutdown.wait()).await 89 | } 90 | 91 | /// Convenience function to call [`Server::listen`] bound to a TCP address. 92 | /// 93 | /// This also sets `TCP_NODELAY` on the incoming connections for performance 94 | /// reasons, as a reasonable default. 95 | pub async fn bind(&self, addr: &SocketAddr) -> Result<()> { 96 | let listener = TcpListener::bind(addr).await?.tap_io(|tcp_stream| { 97 | if let Err(err) = tcp_stream.set_nodelay(true) { 98 | debug!("failed to set TCP_NODELAY on incoming connection: {err:#}"); 99 | } 100 | }); 101 | self.listen(listener).await 102 | } 103 | 104 | /// Send a graceful shutdown signal to the server. 105 | pub fn shutdown(&self) { 106 | // Stop receiving new network connections. 107 | self.shutdown.shutdown(); 108 | // Terminate each of the existing sessions. 109 | self.state.shutdown(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /crates/sshx-server/src/listen.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, future::Future, sync::Arc}; 2 | 3 | use anyhow::Result; 4 | use axum::body::Body; 5 | use axum::serve::Listener; 6 | use http::{header::CONTENT_TYPE, Request}; 7 | use sshx_core::proto::{sshx_service_server::SshxServiceServer, FILE_DESCRIPTOR_SET}; 8 | use tonic::service::Routes as TonicRoutes; 9 | use tower::{make::Shared, steer::Steer, ServiceExt}; 10 | use tower_http::trace::TraceLayer; 11 | 12 | use crate::{grpc::GrpcServer, web, ServerState}; 13 | 14 | /// Bind and listen from the application, with a state and termination signal. 15 | /// 16 | /// This internal method is responsible for multiplexing the HTTP and gRPC 17 | /// servers onto a single, consolidated `hyper` service. 18 | pub(crate) async fn start_server( 19 | state: Arc, 20 | listener: L, 21 | signal: impl Future + Send + 'static, 22 | ) -> Result<()> 23 | where 24 | L: Listener, 25 | L::Addr: Debug, 26 | { 27 | let http_service = web::app() 28 | .with_state(state.clone()) 29 | .layer(TraceLayer::new_for_http()) 30 | .into_service() 31 | .boxed_clone(); 32 | 33 | let grpc_service = TonicRoutes::default() 34 | .add_service(SshxServiceServer::new(GrpcServer::new(state))) 35 | .add_service( 36 | tonic_reflection::server::Builder::configure() 37 | .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) 38 | .build_v1()?, 39 | ) 40 | .into_axum_router() 41 | .layer(TraceLayer::new_for_grpc()) 42 | .into_service() 43 | // This type conversion is necessary because Tonic 0.12 uses Axum 0.7, so its `axum::Router` 44 | // and `axum::Body` are based on an older `axum_core` version. 45 | .map_response(|r| r.map(Body::new)) 46 | .boxed_clone(); 47 | 48 | let svc = Steer::new( 49 | [http_service, grpc_service], 50 | |req: &Request, _services: &[_]| { 51 | let headers = req.headers(); 52 | match headers.get(CONTENT_TYPE) { 53 | Some(content) if content == "application/grpc" => 1, 54 | _ => 0, 55 | } 56 | }, 57 | ); 58 | let make_svc = Shared::new(svc); 59 | 60 | axum::serve(listener, make_svc) 61 | .with_graceful_shutdown(signal) 62 | .await?; 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /crates/sshx-server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::{IpAddr, SocketAddr}, 3 | process::ExitCode, 4 | }; 5 | 6 | use anyhow::Result; 7 | use clap::Parser; 8 | use sshx_server::{Server, ServerOptions}; 9 | use tokio::signal::unix::{signal, SignalKind}; 10 | use tracing::{error, info}; 11 | 12 | /// The sshx server CLI interface. 13 | #[derive(Parser, Debug)] 14 | #[clap(author, version, about, long_about = None)] 15 | struct Args { 16 | /// Specify port to listen on. 17 | #[clap(long, default_value_t = 8051)] 18 | port: u16, 19 | 20 | /// Which IP address or network interface to listen on. 21 | #[clap(long, value_parser, default_value = "::1")] 22 | listen: IpAddr, 23 | 24 | /// Secret used for signing session tokens. 25 | #[clap(long, env = "SSHX_SECRET")] 26 | secret: Option, 27 | 28 | /// Override the origin URL returned by the Open() RPC. 29 | #[clap(long)] 30 | override_origin: Option, 31 | 32 | /// URL of the Redis server that stores session data. 33 | #[clap(long, env = "SSHX_REDIS_URL")] 34 | redis_url: Option, 35 | 36 | /// Hostname of this server, if running multiple servers. 37 | #[clap(long)] 38 | host: Option, 39 | } 40 | 41 | #[tokio::main] 42 | async fn start(args: Args) -> Result<()> { 43 | let addr = SocketAddr::new(args.listen, args.port); 44 | 45 | let mut sigterm = signal(SignalKind::terminate())?; 46 | let mut sigint = signal(SignalKind::interrupt())?; 47 | 48 | let mut options = ServerOptions::default(); 49 | options.secret = args.secret; 50 | options.override_origin = args.override_origin; 51 | options.redis_url = args.redis_url; 52 | options.host = args.host; 53 | 54 | let server = Server::new(options)?; 55 | 56 | let serve_task = async { 57 | info!("server listening at {addr}"); 58 | server.bind(&addr).await 59 | }; 60 | 61 | let signals_task = async { 62 | tokio::select! { 63 | Some(()) = sigterm.recv() => (), 64 | Some(()) = sigint.recv() => (), 65 | else => return Ok(()), 66 | } 67 | info!("gracefully shutting down..."); 68 | server.shutdown(); 69 | Ok(()) 70 | }; 71 | 72 | tokio::try_join!(serve_task, signals_task)?; 73 | Ok(()) 74 | } 75 | 76 | fn main() -> ExitCode { 77 | let args = Args::parse(); 78 | 79 | tracing_subscriber::fmt() 80 | .with_env_filter(std::env::var("RUST_LOG").unwrap_or("info".into())) 81 | .with_writer(std::io::stderr) 82 | .init(); 83 | 84 | match start(args) { 85 | Ok(()) => ExitCode::SUCCESS, 86 | Err(err) => { 87 | error!("{err:?}"); 88 | ExitCode::FAILURE 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /crates/sshx-server/src/session/snapshot.rs: -------------------------------------------------------------------------------- 1 | //! Snapshot and restore sessions from serialized state. 2 | 3 | use std::collections::BTreeMap; 4 | 5 | use anyhow::{ensure, Context, Result}; 6 | use prost::Message; 7 | use sshx_core::{ 8 | proto::{SerializedSession, SerializedShell}, 9 | Sid, Uid, 10 | }; 11 | 12 | use super::{Metadata, Session, State}; 13 | use crate::web::protocol::WsWinsize; 14 | 15 | /// Persist at most this many bytes of output in storage, per shell. 16 | const SHELL_SNAPSHOT_BYTES: u64 = 1 << 15; // 32 KiB 17 | 18 | const MAX_SNAPSHOT_SIZE: usize = 1 << 22; // 4 MiB 19 | 20 | impl Session { 21 | /// Snapshot the session, returning a compressed representation. 22 | pub fn snapshot(&self) -> Result> { 23 | let ids = self.counter.get_current_values(); 24 | let winsizes: BTreeMap = self.source.borrow().iter().cloned().collect(); 25 | let message = SerializedSession { 26 | encrypted_zeros: self.metadata().encrypted_zeros.clone(), 27 | shells: self 28 | .shells 29 | .read() 30 | .iter() 31 | .map(|(sid, shell)| { 32 | // Prune off data until its total length is at most `SHELL_SNAPSHOT_BYTES`. 33 | let mut prefix = 0; 34 | let mut chunk_offset = shell.chunk_offset; 35 | let mut byte_offset = shell.byte_offset; 36 | 37 | for i in 0..shell.data.len() { 38 | if shell.seqnum - byte_offset > SHELL_SNAPSHOT_BYTES { 39 | prefix += 1; 40 | chunk_offset += 1; 41 | byte_offset += shell.data[i].len() as u64; 42 | } else { 43 | break; 44 | } 45 | } 46 | 47 | let winsize = winsizes.get(sid).cloned().unwrap_or_default(); 48 | let shell = SerializedShell { 49 | seqnum: shell.seqnum, 50 | data: shell.data[prefix..].to_vec(), 51 | chunk_offset, 52 | byte_offset, 53 | closed: shell.closed, 54 | winsize_x: winsize.x, 55 | winsize_y: winsize.y, 56 | winsize_rows: winsize.rows.into(), 57 | winsize_cols: winsize.cols.into(), 58 | }; 59 | (sid.0, shell) 60 | }) 61 | .collect(), 62 | next_sid: ids.0 .0, 63 | next_uid: ids.1 .0, 64 | name: self.metadata().name.clone(), 65 | write_password_hash: self.metadata().write_password_hash.clone(), 66 | }; 67 | let data = message.encode_to_vec(); 68 | ensure!(data.len() < MAX_SNAPSHOT_SIZE, "snapshot too large"); 69 | Ok(zstd::bulk::compress(&data, 3)?) 70 | } 71 | 72 | /// Restore the session from a previous compressed snapshot. 73 | pub fn restore(data: &[u8]) -> Result { 74 | let data = zstd::bulk::decompress(data, MAX_SNAPSHOT_SIZE)?; 75 | let message = SerializedSession::decode(&*data)?; 76 | 77 | let metadata = Metadata { 78 | encrypted_zeros: message.encrypted_zeros, 79 | name: message.name, 80 | write_password_hash: message.write_password_hash, 81 | }; 82 | 83 | let session = Self::new(metadata); 84 | let mut shells = session.shells.write(); 85 | let mut winsizes = Vec::new(); 86 | for (sid, shell) in message.shells { 87 | winsizes.push(( 88 | Sid(sid), 89 | WsWinsize { 90 | x: shell.winsize_x, 91 | y: shell.winsize_y, 92 | rows: shell.winsize_rows.try_into().context("rows overflow")?, 93 | cols: shell.winsize_cols.try_into().context("cols overflow")?, 94 | }, 95 | )); 96 | let shell = State { 97 | seqnum: shell.seqnum, 98 | data: shell.data, 99 | chunk_offset: shell.chunk_offset, 100 | byte_offset: shell.byte_offset, 101 | closed: shell.closed, 102 | notify: Default::default(), 103 | }; 104 | shells.insert(Sid(sid), shell); 105 | } 106 | drop(shells); 107 | session.source.send_replace(winsizes); 108 | session 109 | .counter 110 | .set_current_values(Sid(message.next_sid), Uid(message.next_uid)); 111 | 112 | Ok(session) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /crates/sshx-server/src/state.rs: -------------------------------------------------------------------------------- 1 | //! Stateful components of the server, managing multiple sessions. 2 | 3 | use std::pin::pin; 4 | use std::sync::Arc; 5 | use std::time::Duration; 6 | 7 | use anyhow::Result; 8 | use dashmap::DashMap; 9 | use hmac::{Hmac, Mac as _}; 10 | use sha2::Sha256; 11 | use sshx_core::rand_alphanumeric; 12 | use tokio::time; 13 | use tokio_stream::StreamExt; 14 | use tracing::error; 15 | 16 | use self::mesh::StorageMesh; 17 | use crate::session::Session; 18 | use crate::ServerOptions; 19 | 20 | pub mod mesh; 21 | 22 | /// Timeout for a disconnected session to be evicted and closed. 23 | /// 24 | /// If a session has no backend clients making connections in this interval, 25 | /// then its updated timestamp will be out-of-date, so we close it and remove it 26 | /// from the state to reduce memory usage. 27 | const DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300); 28 | 29 | /// Shared state object for global server logic. 30 | pub struct ServerState { 31 | /// Message authentication code for signing tokens. 32 | mac: Hmac, 33 | 34 | /// Override the origin returned for the Open() RPC. 35 | override_origin: Option, 36 | 37 | /// A concurrent map of session IDs to session objects. 38 | store: DashMap>, 39 | 40 | /// Storage and distributed communication provider, if enabled. 41 | mesh: Option, 42 | } 43 | 44 | impl ServerState { 45 | /// Create an empty server state using the given secret. 46 | pub fn new(options: ServerOptions) -> Result { 47 | let secret = options.secret.unwrap_or_else(|| rand_alphanumeric(22)); 48 | let mesh = match options.redis_url { 49 | Some(url) => Some(StorageMesh::new(&url, options.host.as_deref())?), 50 | None => None, 51 | }; 52 | Ok(Self { 53 | mac: Hmac::new_from_slice(secret.as_bytes()).unwrap(), 54 | override_origin: options.override_origin, 55 | store: DashMap::new(), 56 | mesh, 57 | }) 58 | } 59 | 60 | /// Returns the message authentication code used for signing tokens. 61 | pub fn mac(&self) -> Hmac { 62 | self.mac.clone() 63 | } 64 | 65 | /// Returns the override origin for the Open() RPC. 66 | pub fn override_origin(&self) -> Option { 67 | self.override_origin.clone() 68 | } 69 | 70 | /// Lookup a local session by name. 71 | pub fn lookup(&self, name: &str) -> Option> { 72 | self.store.get(name).map(|s| s.clone()) 73 | } 74 | 75 | /// Insert a session into the local store. 76 | pub fn insert(&self, name: &str, session: Arc) { 77 | if let Some(mesh) = &self.mesh { 78 | let name = name.to_string(); 79 | let session = session.clone(); 80 | let mesh = mesh.clone(); 81 | tokio::spawn(async move { 82 | mesh.background_sync(&name, session).await; 83 | }); 84 | } 85 | if let Some(prev_session) = self.store.insert(name.to_string(), session) { 86 | prev_session.shutdown(); 87 | } 88 | } 89 | 90 | /// Remove a session from the local store. 91 | pub fn remove(&self, name: &str) -> bool { 92 | if let Some((_, session)) = self.store.remove(name) { 93 | session.shutdown(); 94 | true 95 | } else { 96 | false 97 | } 98 | } 99 | 100 | /// Close a session permanently on this and other servers. 101 | pub async fn close_session(&self, name: &str) -> Result<()> { 102 | self.remove(name); 103 | if let Some(mesh) = &self.mesh { 104 | mesh.mark_closed(name).await?; 105 | } 106 | Ok(()) 107 | } 108 | 109 | /// Connect to a session by name from the `sshx` client, which provides the 110 | /// actual terminal backend. 111 | pub async fn backend_connect(&self, name: &str) -> Result>> { 112 | if let Some(session) = self.lookup(name) { 113 | return Ok(Some(session)); 114 | } 115 | 116 | if let Some(mesh) = &self.mesh { 117 | let (owner, snapshot) = mesh.get_owner_snapshot(name).await?; 118 | if let Some(snapshot) = snapshot { 119 | let session = Arc::new(Session::restore(&snapshot)?); 120 | self.insert(name, session.clone()); 121 | if let Some(owner) = owner { 122 | mesh.notify_transfer(name, &owner).await?; 123 | } 124 | return Ok(Some(session)); 125 | } 126 | } 127 | 128 | Ok(None) 129 | } 130 | 131 | /// Connect to a session from a web browser frontend, possibly redirecting. 132 | pub async fn frontend_connect( 133 | &self, 134 | name: &str, 135 | ) -> Result, Option>> { 136 | if let Some(session) = self.lookup(name) { 137 | return Ok(Ok(session)); 138 | } 139 | 140 | if let Some(mesh) = &self.mesh { 141 | let mut owner = mesh.get_owner(name).await?; 142 | if owner.is_some() && owner.as_deref() == mesh.host() { 143 | // Do not redirect back to the same server. 144 | owner = None; 145 | } 146 | return Ok(Err(owner)); 147 | } 148 | 149 | Ok(Err(None)) 150 | } 151 | 152 | /// Listen for and remove sessions that are transferred away from this host. 153 | pub async fn listen_for_transfers(&self) { 154 | if let Some(mesh) = &self.mesh { 155 | let mut transfers = pin!(mesh.listen_for_transfers()); 156 | while let Some(name) = transfers.next().await { 157 | self.remove(&name); 158 | } 159 | } 160 | } 161 | 162 | /// Close all sessions that have been disconnected for too long. 163 | pub async fn close_old_sessions(&self) { 164 | loop { 165 | time::sleep(DISCONNECTED_SESSION_EXPIRY / 5).await; 166 | let mut to_close = Vec::new(); 167 | for entry in &self.store { 168 | let session = entry.value(); 169 | if session.last_accessed().elapsed() > DISCONNECTED_SESSION_EXPIRY { 170 | to_close.push(entry.key().clone()); 171 | } 172 | } 173 | for name in to_close { 174 | if let Err(err) = self.close_session(&name).await { 175 | error!(?err, "failed to close old session {name}"); 176 | } 177 | } 178 | } 179 | } 180 | 181 | /// Send a graceful shutdown signal to every session. 182 | pub fn shutdown(&self) { 183 | for entry in &self.store { 184 | entry.value().shutdown(); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /crates/sshx-server/src/state/mesh.rs: -------------------------------------------------------------------------------- 1 | //! Storage and distributed communication. 2 | 3 | use std::{pin::pin, sync::Arc, time::Duration}; 4 | 5 | use anyhow::Result; 6 | use redis::AsyncCommands; 7 | use tokio::time; 8 | use tokio_stream::{Stream, StreamExt}; 9 | use tracing::error; 10 | 11 | use crate::session::Session; 12 | 13 | /// Interval for syncing the latest session state into persistent storage. 14 | const STORAGE_SYNC_INTERVAL: Duration = Duration::from_secs(20); 15 | 16 | /// Length of time a key lasts in Redis before it is expired. 17 | const STORAGE_EXPIRY: Duration = Duration::from_secs(300); 18 | 19 | fn set_opts() -> redis::SetOptions { 20 | redis::SetOptions::default() 21 | .with_expiration(redis::SetExpiry::PX(STORAGE_EXPIRY.as_millis() as u64)) 22 | } 23 | 24 | /// Communication with a distributed mesh of sshx server nodes. 25 | /// 26 | /// This uses a Redis instance to persist data across restarts, as well as a 27 | /// pub/sub channel to keep be notified of when another node becomes the owner 28 | /// of an active session. 29 | /// 30 | /// All servers must be accessible to each other through TCP mesh networking, 31 | /// since requests are forwarded to the controller of a given session. 32 | #[derive(Clone)] 33 | pub struct StorageMesh { 34 | redis: deadpool_redis::Pool, 35 | redis_pubsub: redis::Client, 36 | host: Option, 37 | } 38 | 39 | impl StorageMesh { 40 | /// Construct a new storage object from Redis URL. 41 | pub fn new(redis_url: &str, host: Option<&str>) -> Result { 42 | let redis = deadpool_redis::Config::from_url(redis_url) 43 | .builder()? 44 | .max_size(4) 45 | .wait_timeout(Some(Duration::from_secs(5))) 46 | .runtime(deadpool_redis::Runtime::Tokio1) 47 | .build()?; 48 | 49 | // Separate `redis::Client` just for pub/sub connections. 50 | // 51 | // At time of writing, deadpool-redis has not been updated to support the new 52 | // pub/sub client APIs in Rust. This is a temporary workaround that creates a 53 | // new Redis client on the side, bypassing the connection pool. 54 | // 55 | // Reference: https://github.com/deadpool-rs/deadpool/issues/226 56 | let redis_pubsub = redis::Client::open(redis_url)?; 57 | 58 | Ok(Self { 59 | redis, 60 | redis_pubsub, 61 | host: host.map(|s| s.to_string()), 62 | }) 63 | } 64 | 65 | /// Returns the hostname of this server, if running in mesh node. 66 | pub fn host(&self) -> Option<&str> { 67 | self.host.as_deref() 68 | } 69 | 70 | /// Retrieve the hostname of the owner of a session. 71 | pub async fn get_owner(&self, name: &str) -> Result> { 72 | let mut conn = self.redis.get().await?; 73 | let (owner, closed) = redis::pipe() 74 | .get(format!("session:{{{name}}}:owner")) 75 | .get(format!("session:{{{name}}}:closed")) 76 | .query_async(&mut conn) 77 | .await?; 78 | if closed { 79 | Ok(None) 80 | } else { 81 | Ok(owner) 82 | } 83 | } 84 | 85 | /// Retrieve the owner and snapshot of a session. 86 | pub async fn get_owner_snapshot( 87 | &self, 88 | name: &str, 89 | ) -> Result<(Option, Option>)> { 90 | let mut conn = self.redis.get().await?; 91 | let (owner, snapshot, closed) = redis::pipe() 92 | .get(format!("session:{{{name}}}:owner")) 93 | .get(format!("session:{{{name}}}:snapshot")) 94 | .get(format!("session:{{{name}}}:closed")) 95 | .query_async(&mut conn) 96 | .await?; 97 | if closed { 98 | Ok((None, None)) 99 | } else { 100 | Ok((owner, snapshot)) 101 | } 102 | } 103 | 104 | /// Periodically set the owner and snapshot of a session. 105 | pub async fn background_sync(&self, name: &str, session: Arc) { 106 | let mut interval = time::interval(STORAGE_SYNC_INTERVAL); 107 | interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay); 108 | loop { 109 | tokio::select! { 110 | _ = interval.tick() => {} 111 | _ = session.sync_now_wait() => {} 112 | _ = session.terminated() => break, 113 | } 114 | let mut conn = match self.redis.get().await { 115 | Ok(conn) => conn, 116 | Err(err) => { 117 | error!(?err, "failed to connect to redis for sync"); 118 | continue; 119 | } 120 | }; 121 | let snapshot = match session.snapshot() { 122 | Ok(snapshot) => snapshot, 123 | Err(err) => { 124 | error!(?err, "failed to snapshot session {name}"); 125 | continue; 126 | } 127 | }; 128 | let mut pipe = redis::pipe(); 129 | if let Some(host) = &self.host { 130 | pipe.set_options(format!("session:{{{name}}}:owner"), host, set_opts()); 131 | } 132 | pipe.set_options(format!("session:{{{name}}}:snapshot"), snapshot, set_opts()); 133 | match pipe.query_async(&mut conn).await { 134 | Ok(()) => {} 135 | Err(err) => error!(?err, "failed to sync session {name}"), 136 | } 137 | } 138 | } 139 | 140 | /// Mark a session as closed, so it will expire and never be accessed again. 141 | pub async fn mark_closed(&self, name: &str) -> Result<()> { 142 | let mut conn = self.redis.get().await?; 143 | let (owner,): (Option,) = redis::pipe() 144 | .get_del(format!("session:{{{name}}}:owner")) 145 | .del(format!("session:{{{name}}}:snapshot")) 146 | .ignore() 147 | .set_options(format!("session:{{{name}}}:closed"), true, set_opts()) 148 | .ignore() 149 | .query_async(&mut conn) 150 | .await?; 151 | if let Some(owner) = owner { 152 | self.notify_transfer(name, &owner).await?; 153 | } 154 | Ok(()) 155 | } 156 | 157 | /// Notify a host that a session has been transferred. 158 | pub async fn notify_transfer(&self, name: &str, host: &str) -> Result<()> { 159 | let mut conn = self.redis.get().await?; 160 | () = conn.publish(format!("transfers:{host}"), name).await?; 161 | Ok(()) 162 | } 163 | 164 | /// Listen for sessions that are transferred away from this host. 165 | pub fn listen_for_transfers(&self) -> impl Stream + Send + '_ { 166 | async_stream::stream! { 167 | let Some(host) = &self.host else { 168 | // If not in a mesh, there are no transfers. 169 | return; 170 | }; 171 | 172 | loop { 173 | // Requires an owned, non-pool connection for ownership reasons. 174 | let mut pubsub = match self.redis_pubsub.get_async_pubsub().await { 175 | Ok(pubsub) => pubsub, 176 | Err(err) => { 177 | error!(?err, "failed to connect to redis for pub/sub"); 178 | time::sleep(Duration::from_secs(5)).await; 179 | continue; 180 | } 181 | }; 182 | if let Err(err) = pubsub.subscribe(format!("transfers:{host}")).await { 183 | error!(?err, "failed to subscribe to transfers"); 184 | time::sleep(Duration::from_secs(1)).await; 185 | continue; 186 | } 187 | 188 | let mut msg_stream = pin!(pubsub.into_on_message()); 189 | while let Some(msg) = msg_stream.next().await { 190 | match msg.get_payload::() { 191 | Ok(payload) => yield payload, 192 | Err(err) => { 193 | error!(?err, "failed to parse transfers message"); 194 | continue; 195 | } 196 | }; 197 | } 198 | } 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /crates/sshx-server/src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions shared among server logic. 2 | 3 | use std::fmt::Debug; 4 | use std::future::Future; 5 | use std::sync::atomic::{AtomicBool, Ordering}; 6 | use std::sync::Arc; 7 | 8 | use tokio::sync::Notify; 9 | 10 | /// A cloneable structure that handles shutdown signals. 11 | #[derive(Clone)] 12 | pub struct Shutdown { 13 | inner: Arc<(AtomicBool, Notify)>, 14 | } 15 | 16 | impl Shutdown { 17 | /// Construct a new [`Shutdown`] object. 18 | pub fn new() -> Self { 19 | Self { 20 | inner: Arc::new((AtomicBool::new(false), Notify::new())), 21 | } 22 | } 23 | 24 | /// Send a shutdown signal to all listeners. 25 | pub fn shutdown(&self) { 26 | self.inner.0.swap(true, Ordering::Relaxed); 27 | self.inner.1.notify_waiters(); 28 | } 29 | 30 | /// Returns whether the shutdown signal has been previously sent. 31 | pub fn is_terminated(&self) -> bool { 32 | self.inner.0.load(Ordering::Relaxed) 33 | } 34 | 35 | /// Wait for the shutdown signal, if it has not already been sent. 36 | pub fn wait(&'_ self) -> impl Future + Send { 37 | let inner = self.inner.clone(); 38 | async move { 39 | // Initial fast check 40 | if !inner.0.load(Ordering::Relaxed) { 41 | let notify = inner.1.notified(); 42 | // Second check to avoid "missed wakeup" race conditions 43 | if !inner.0.load(Ordering::Relaxed) { 44 | notify.await; 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | impl Default for Shutdown { 52 | fn default() -> Self { 53 | Self::new() 54 | } 55 | } 56 | 57 | impl Debug for Shutdown { 58 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 59 | f.debug_struct("Shutdown") 60 | .field("is_terminated", &self.inner.0.load(Ordering::Relaxed)) 61 | .finish() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/sshx-server/src/web.rs: -------------------------------------------------------------------------------- 1 | //! HTTP and WebSocket handlers for the sshx web interface. 2 | 3 | use std::sync::Arc; 4 | 5 | use axum::routing::{any, get_service}; 6 | use axum::Router; 7 | use tower_http::services::{ServeDir, ServeFile}; 8 | 9 | use crate::ServerState; 10 | 11 | pub mod protocol; 12 | mod socket; 13 | 14 | /// Returns the web application server, routed with Axum. 15 | pub fn app() -> Router> { 16 | let root_spa = ServeFile::new("build/spa.html") 17 | .precompressed_gzip() 18 | .precompressed_br(); 19 | 20 | // Serves static SvelteKit build files. 21 | let static_files = ServeDir::new("build") 22 | .precompressed_gzip() 23 | .precompressed_br() 24 | .fallback(root_spa); 25 | 26 | Router::new() 27 | .nest("/api", backend()) 28 | .fallback_service(get_service(static_files)) 29 | } 30 | 31 | /// Routes for the backend web API server. 32 | fn backend() -> Router> { 33 | Router::new().route("/s/{name}", any(socket::get_session_ws)) 34 | } 35 | -------------------------------------------------------------------------------- /crates/sshx-server/src/web/protocol.rs: -------------------------------------------------------------------------------- 1 | //! Serializable types sent and received by the web server. 2 | 3 | use bytes::Bytes; 4 | use serde::{Deserialize, Serialize}; 5 | use sshx_core::{Sid, Uid}; 6 | 7 | /// Real-time message conveying the position and size of a terminal. 8 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct WsWinsize { 11 | /// The top-left x-coordinate of the window, offset from origin. 12 | pub x: i32, 13 | /// The top-left y-coordinate of the window, offset from origin. 14 | pub y: i32, 15 | /// The number of rows in the window. 16 | pub rows: u16, 17 | /// The number of columns in the terminal. 18 | pub cols: u16, 19 | } 20 | 21 | impl Default for WsWinsize { 22 | fn default() -> Self { 23 | WsWinsize { 24 | x: 0, 25 | y: 0, 26 | rows: 24, 27 | cols: 80, 28 | } 29 | } 30 | } 31 | 32 | /// Real-time message providing information about a user. 33 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct WsUser { 36 | /// The user's display name. 37 | pub name: String, 38 | /// Live coordinates of the mouse cursor, if available. 39 | pub cursor: Option<(i32, i32)>, 40 | /// Currently focused terminal window ID. 41 | pub focus: Option, 42 | /// Whether the user has write permissions in the session. 43 | pub can_write: bool, 44 | } 45 | 46 | /// A real-time message sent from the server over WebSocket. 47 | #[derive(Serialize, Deserialize, Debug, Clone)] 48 | #[serde(rename_all = "camelCase")] 49 | pub enum WsServer { 50 | /// Initial server message, with the user's ID and session metadata. 51 | Hello(Uid, String), 52 | /// The user's authentication was invalid. 53 | InvalidAuth(), 54 | /// A snapshot of all current users in the session. 55 | Users(Vec<(Uid, WsUser)>), 56 | /// Info about a single user in the session: joined, left, or changed. 57 | UserDiff(Uid, Option), 58 | /// Notification when the set of open shells has changed. 59 | Shells(Vec<(Sid, WsWinsize)>), 60 | /// Subscription results, in the form of terminal data chunks. 61 | Chunks(Sid, u64, Vec), 62 | /// Get a chat message tuple `(uid, name, text)` from the room. 63 | Hear(Uid, String, String), 64 | /// Forward a latency measurement between the server and backend shell. 65 | ShellLatency(u64), 66 | /// Echo back a timestamp, for the the client's own latency measurement. 67 | Pong(u64), 68 | /// Alert the client of an application error. 69 | Error(String), 70 | } 71 | 72 | /// A real-time message sent from the client over WebSocket. 73 | #[derive(Serialize, Deserialize, Debug, Clone)] 74 | #[serde(rename_all = "camelCase")] 75 | pub enum WsClient { 76 | /// Authenticate the user's encryption key by zeros block and write password 77 | /// (if provided). 78 | Authenticate(Bytes, Option), 79 | /// Set the name of the current user. 80 | SetName(String), 81 | /// Send real-time information about the user's cursor. 82 | SetCursor(Option<(i32, i32)>), 83 | /// Set the currently focused shell. 84 | SetFocus(Option), 85 | /// Create a new shell. 86 | Create(i32, i32), 87 | /// Close a specific shell. 88 | Close(Sid), 89 | /// Move a shell window to a new position and focus it. 90 | Move(Sid, Option), 91 | /// Add user data to a given shell. 92 | Data(Sid, Bytes, u64), 93 | /// Subscribe to a shell, starting at a given chunk index. 94 | Subscribe(Sid, u64), 95 | /// Send a a chat message to the room. 96 | Chat(String), 97 | /// Send a ping to the server, for latency measurement. 98 | Ping(u64), 99 | } 100 | -------------------------------------------------------------------------------- /crates/sshx-server/tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap}; 2 | use std::net::SocketAddr; 3 | use std::sync::Arc; 4 | use std::time::Duration; 5 | 6 | use anyhow::{ensure, Result}; 7 | use axum::serve::ListenerExt; 8 | use futures_util::{SinkExt, StreamExt}; 9 | use http::StatusCode; 10 | use sshx::encrypt::Encrypt; 11 | use sshx_core::proto::sshx_service_client::SshxServiceClient; 12 | use sshx_core::{Sid, Uid}; 13 | use sshx_server::{ 14 | state::ServerState, 15 | web::protocol::{WsClient, WsServer, WsUser, WsWinsize}, 16 | Server, 17 | }; 18 | use tokio::net::{TcpListener, TcpStream}; 19 | use tokio::time; 20 | use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; 21 | use tonic::transport::Channel; 22 | 23 | /// An ephemeral, isolated server that is created for each test. 24 | pub struct TestServer { 25 | local_addr: SocketAddr, 26 | server: Arc, 27 | } 28 | 29 | impl TestServer { 30 | /// Create a fresh server listening on an unused local port for testing. 31 | /// 32 | /// Returns an object with the local address, as well as a custom [`Drop`] 33 | /// implementation that gracefully shuts down the server. 34 | pub async fn new() -> Self { 35 | let listener = TcpListener::bind("[::1]:0").await.unwrap(); 36 | let local_addr = listener.local_addr().unwrap(); 37 | 38 | let server = Arc::new(Server::new(Default::default()).unwrap()); 39 | { 40 | let server = Arc::clone(&server); 41 | let listener = listener.tap_io(|tcp_stream| { 42 | _ = tcp_stream.set_nodelay(true); 43 | }); 44 | tokio::spawn(async move { 45 | server.listen(listener).await.unwrap(); 46 | }); 47 | } 48 | 49 | TestServer { local_addr, server } 50 | } 51 | 52 | /// Returns the local TCP address of this server. 53 | pub fn local_addr(&self) -> SocketAddr { 54 | self.local_addr 55 | } 56 | 57 | /// Returns the HTTP/2 base endpoint URI for this server. 58 | pub fn endpoint(&self) -> String { 59 | format!("http://{}", self.local_addr) 60 | } 61 | 62 | /// Returns the WebSocket endpoint for streaming connections to a session. 63 | pub fn ws_endpoint(&self, name: &str) -> String { 64 | format!("ws://{}/api/s/{}", self.local_addr, name) 65 | } 66 | 67 | /// Creates a gRPC client connected to this server. 68 | pub async fn grpc_client(&self) -> SshxServiceClient { 69 | SshxServiceClient::connect(self.endpoint()).await.unwrap() 70 | } 71 | 72 | /// Return the current server state object. 73 | pub fn state(&self) -> Arc { 74 | self.server.state() 75 | } 76 | } 77 | 78 | impl Drop for TestServer { 79 | fn drop(&mut self) { 80 | self.server.shutdown(); 81 | } 82 | } 83 | 84 | /// A WebSocket client that interacts with the server, used for testing. 85 | pub struct ClientSocket { 86 | inner: WebSocketStream>, 87 | encrypt: Encrypt, 88 | write_encrypt: Option, 89 | 90 | pub user_id: Uid, 91 | pub users: BTreeMap, 92 | pub shells: BTreeMap, 93 | pub data: HashMap, 94 | pub messages: Vec<(Uid, String, String)>, 95 | pub errors: Vec, 96 | } 97 | 98 | impl ClientSocket { 99 | /// Connect to a WebSocket endpoint. 100 | pub async fn connect(uri: &str, key: &str, write_password: Option<&str>) -> Result { 101 | let (stream, resp) = tokio_tungstenite::connect_async(uri).await?; 102 | ensure!(resp.status() == StatusCode::SWITCHING_PROTOCOLS); 103 | 104 | let mut this = Self { 105 | inner: stream, 106 | encrypt: Encrypt::new(key), 107 | write_encrypt: write_password.map(Encrypt::new), 108 | user_id: Uid(0), 109 | users: BTreeMap::new(), 110 | shells: BTreeMap::new(), 111 | data: HashMap::new(), 112 | messages: Vec::new(), 113 | errors: Vec::new(), 114 | }; 115 | this.authenticate().await; 116 | Ok(this) 117 | } 118 | 119 | async fn authenticate(&mut self) { 120 | let encrypted_zeros = self.encrypt.zeros().into(); 121 | let write_zeros = self.write_encrypt.as_ref().map(|e| e.zeros().into()); 122 | 123 | self.send(WsClient::Authenticate(encrypted_zeros, write_zeros)) 124 | .await; 125 | } 126 | 127 | pub async fn send(&mut self, msg: WsClient) { 128 | let mut buf = Vec::new(); 129 | ciborium::ser::into_writer(&msg, &mut buf).unwrap(); 130 | self.inner.send(Message::Binary(buf.into())).await.unwrap(); 131 | } 132 | 133 | pub async fn send_input(&mut self, id: Sid, data: &[u8]) { 134 | let offset = 42; // arbitrary, don't reuse the offset in real code though 135 | let data = self.encrypt.segment(0x200000000, offset, data); 136 | self.send(WsClient::Data(id, data.into(), offset)).await; 137 | } 138 | 139 | async fn recv(&mut self) -> Option { 140 | loop { 141 | match self.inner.next().await.transpose().unwrap() { 142 | Some(Message::Text(_)) => panic!("unexpected text message over WebSocket"), 143 | Some(Message::Binary(msg)) => { 144 | break Some(ciborium::de::from_reader(&*msg).unwrap()) 145 | } 146 | Some(_) => (), // ignore other message types, keep looping 147 | None => break None, 148 | } 149 | } 150 | } 151 | 152 | pub async fn expect_close(&mut self, code: u16) { 153 | let msg = self.inner.next().await.unwrap().unwrap(); 154 | match msg { 155 | Message::Close(Some(frame)) => assert!(frame.code == code.into()), 156 | _ => panic!("unexpected non-close message over WebSocket: {:?}", msg), 157 | } 158 | } 159 | 160 | pub async fn flush(&mut self) { 161 | const FLUSH_DURATION: Duration = Duration::from_millis(50); 162 | let flush_task = async { 163 | while let Some(msg) = self.recv().await { 164 | match msg { 165 | WsServer::Hello(user_id, _) => self.user_id = user_id, 166 | WsServer::InvalidAuth() => panic!("invalid authentication"), 167 | WsServer::Users(users) => self.users = BTreeMap::from_iter(users), 168 | WsServer::UserDiff(id, maybe_user) => { 169 | self.users.remove(&id); 170 | if let Some(user) = maybe_user { 171 | self.users.insert(id, user); 172 | } 173 | } 174 | WsServer::Shells(shells) => self.shells = BTreeMap::from_iter(shells), 175 | WsServer::Chunks(id, seqnum, chunks) => { 176 | let value = self.data.entry(id).or_default(); 177 | assert_eq!(seqnum, value.len() as u64); 178 | for buf in chunks { 179 | let plaintext = self.encrypt.segment( 180 | 0x100000000 | id.0 as u64, 181 | value.len() as u64, 182 | &buf, 183 | ); 184 | value.push_str(std::str::from_utf8(&plaintext).unwrap()); 185 | } 186 | } 187 | WsServer::Hear(id, name, msg) => { 188 | self.messages.push((id, name, msg)); 189 | } 190 | WsServer::ShellLatency(_) => {} 191 | WsServer::Pong(_) => {} 192 | WsServer::Error(err) => self.errors.push(err), 193 | } 194 | } 195 | }; 196 | time::timeout(FLUSH_DURATION, flush_task).await.ok(); 197 | } 198 | 199 | pub fn read(&self, id: Sid) -> &str { 200 | self.data.get(&id).map(|s| &**s).unwrap_or("") 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /crates/sshx-server/tests/simple.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sshx::encrypt::Encrypt; 3 | use sshx_core::proto::*; 4 | 5 | use crate::common::*; 6 | 7 | pub mod common; 8 | 9 | #[tokio::test] 10 | async fn test_rpc() -> Result<()> { 11 | let server = TestServer::new().await; 12 | let mut client = server.grpc_client().await; 13 | 14 | let req = OpenRequest { 15 | origin: "sshx.io".into(), 16 | encrypted_zeros: Encrypt::new("").zeros().into(), 17 | name: String::new(), 18 | write_password_hash: None, 19 | }; 20 | let resp = client.open(req).await?; 21 | assert!(!resp.into_inner().name.is_empty()); 22 | 23 | Ok(()) 24 | } 25 | 26 | #[tokio::test] 27 | async fn test_web_get() -> Result<()> { 28 | let server = TestServer::new().await; 29 | 30 | let resp = reqwest::get(server.endpoint()).await?; 31 | assert!(!resp.status().is_server_error()); 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /crates/sshx-server/tests/snapshot.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use sshx::{controller::Controller, runner::Runner}; 5 | use sshx_core::{Sid, Uid}; 6 | use sshx_server::{ 7 | session::Session, 8 | web::protocol::{WsClient, WsWinsize}, 9 | }; 10 | 11 | use crate::common::*; 12 | 13 | pub mod common; 14 | 15 | #[tokio::test] 16 | async fn test_basic_restore() -> Result<()> { 17 | let server = TestServer::new().await; 18 | 19 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 20 | let name = controller.name().to_owned(); 21 | let key = controller.encryption_key().to_owned(); 22 | tokio::spawn(async move { controller.run().await }); 23 | 24 | let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; 25 | s.flush().await; 26 | assert_eq!(s.user_id, Uid(1)); 27 | 28 | s.send(WsClient::Create(0, 0)).await; 29 | s.flush().await; 30 | 31 | let new_size = WsWinsize { 32 | x: 42, 33 | y: 105, 34 | rows: 200, 35 | cols: 20, 36 | }; 37 | 38 | s.send_input(Sid(1), b"hello there!").await; 39 | s.send_input(Sid(1), b" - another message").await; 40 | s.send(WsClient::Move(Sid(1), Some(new_size))).await; 41 | s.flush().await; 42 | assert!(s.shells.contains_key(&Sid(1))); 43 | 44 | // Replace the shell with its snapshot. 45 | let data = server.state().lookup(&name).unwrap().snapshot()?; 46 | server 47 | .state() 48 | .insert(&name, Arc::new(Session::restore(&data)?)); 49 | 50 | let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; 51 | s.send(WsClient::Subscribe(Sid(1), 0)).await; 52 | s.flush().await; 53 | 54 | assert_eq!(s.read(Sid(1)), "hello there! - another message"); 55 | assert_eq!(s.shells.get(&Sid(1)).unwrap(), &new_size); 56 | 57 | Ok(()) 58 | } 59 | -------------------------------------------------------------------------------- /crates/sshx-server/tests/with_client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use sshx::{controller::Controller, encrypt::Encrypt, runner::Runner}; 3 | use sshx_core::{ 4 | proto::{server_update::ServerMessage, NewShell, TerminalInput}, 5 | Sid, Uid, 6 | }; 7 | use sshx_server::web::protocol::{WsClient, WsWinsize}; 8 | use tokio::time::{self, Duration}; 9 | 10 | use crate::common::*; 11 | 12 | pub mod common; 13 | 14 | #[tokio::test] 15 | async fn test_handshake() -> Result<()> { 16 | let server = TestServer::new().await; 17 | let controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 18 | controller.close().await?; 19 | Ok(()) 20 | } 21 | 22 | #[tokio::test] 23 | async fn test_command() -> Result<()> { 24 | let server = TestServer::new().await; 25 | let runner = Runner::Shell("/bin/bash".into()); 26 | let mut controller = Controller::new(&server.endpoint(), "", runner, false).await?; 27 | 28 | let session = server 29 | .state() 30 | .lookup(controller.name()) 31 | .context("couldn't find session in server state")?; 32 | 33 | let updates = session.update_tx(); 34 | let new_shell = NewShell { id: 1, x: 0, y: 0 }; 35 | updates.send(ServerMessage::CreateShell(new_shell)).await?; 36 | 37 | let key = controller.encryption_key(); 38 | let encrypt = Encrypt::new(key); 39 | let offset = 4242; 40 | let data = TerminalInput { 41 | id: 1, 42 | data: encrypt.segment(0x200000000, offset, b"ls\r\n").into(), 43 | offset, 44 | }; 45 | updates.send(ServerMessage::Input(data)).await?; 46 | 47 | tokio::select! { 48 | _ = controller.run() => (), 49 | _ = time::sleep(Duration::from_millis(1000)) => (), 50 | }; 51 | controller.close().await?; 52 | Ok(()) 53 | } 54 | 55 | #[tokio::test] 56 | async fn test_ws_missing() -> Result<()> { 57 | let server = TestServer::new().await; 58 | 59 | let bad_endpoint = format!("ws://{}/not/an/endpoint", server.local_addr()); 60 | assert!(ClientSocket::connect(&bad_endpoint, "", None) 61 | .await 62 | .is_err()); 63 | 64 | let mut s = ClientSocket::connect(&server.ws_endpoint("foobar"), "", None).await?; 65 | s.expect_close(4404).await; 66 | 67 | Ok(()) 68 | } 69 | 70 | #[tokio::test] 71 | async fn test_ws_basic() -> Result<()> { 72 | let server = TestServer::new().await; 73 | 74 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 75 | let name = controller.name().to_owned(); 76 | let key = controller.encryption_key().to_owned(); 77 | tokio::spawn(async move { controller.run().await }); 78 | 79 | let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; 80 | s.flush().await; 81 | assert_eq!(s.user_id, Uid(1)); 82 | 83 | s.send(WsClient::Create(0, 0)).await; 84 | s.flush().await; 85 | assert_eq!(s.shells.len(), 1); 86 | assert!(s.shells.contains_key(&Sid(1))); 87 | 88 | s.send(WsClient::Subscribe(Sid(1), 0)).await; 89 | assert_eq!(s.read(Sid(1)), ""); 90 | 91 | s.send_input(Sid(1), b"hello!").await; 92 | s.flush().await; 93 | assert_eq!(s.read(Sid(1)), "hello!"); 94 | 95 | s.send_input(Sid(1), b" 123").await; 96 | s.flush().await; 97 | assert_eq!(s.read(Sid(1)), "hello! 123"); 98 | 99 | Ok(()) 100 | } 101 | 102 | #[tokio::test] 103 | async fn test_ws_resize() -> Result<()> { 104 | let server = TestServer::new().await; 105 | 106 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 107 | let name = controller.name().to_owned(); 108 | let key = controller.encryption_key().to_owned(); 109 | tokio::spawn(async move { controller.run().await }); 110 | 111 | let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; 112 | 113 | s.send(WsClient::Move(Sid(1), None)).await; // error: does not exist yet! 114 | s.flush().await; 115 | assert_eq!(s.errors.len(), 1); 116 | 117 | s.send(WsClient::Create(0, 0)).await; 118 | s.flush().await; 119 | assert_eq!(s.shells.len(), 1); 120 | assert_eq!(*s.shells.get(&Sid(1)).unwrap(), WsWinsize::default()); 121 | 122 | let new_size = WsWinsize { 123 | x: 42, 124 | y: 105, 125 | rows: 200, 126 | cols: 20, 127 | }; 128 | s.send(WsClient::Move(Sid(1), Some(new_size))).await; 129 | s.send(WsClient::Move(Sid(2), Some(new_size))).await; // error: does not exist 130 | s.flush().await; 131 | assert_eq!(s.shells.len(), 1); 132 | assert_eq!(*s.shells.get(&Sid(1)).unwrap(), new_size); 133 | assert_eq!(s.errors.len(), 2); 134 | 135 | s.send(WsClient::Close(Sid(1))).await; 136 | s.flush().await; 137 | assert_eq!(s.shells.len(), 0); 138 | 139 | s.send(WsClient::Move(Sid(1), None)).await; // error: shell was closed 140 | s.flush().await; 141 | assert_eq!(s.errors.len(), 3); 142 | 143 | Ok(()) 144 | } 145 | 146 | #[tokio::test] 147 | async fn test_users_join() -> Result<()> { 148 | let server = TestServer::new().await; 149 | 150 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 151 | let name = controller.name().to_owned(); 152 | let key = controller.encryption_key().to_owned(); 153 | tokio::spawn(async move { controller.run().await }); 154 | 155 | let endpoint = server.ws_endpoint(&name); 156 | let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?; 157 | s1.flush().await; 158 | assert_eq!(s1.users.len(), 1); 159 | 160 | let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?; 161 | s2.flush().await; 162 | assert_eq!(s2.users.len(), 2); 163 | 164 | drop(s2); 165 | let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?; 166 | s3.flush().await; 167 | assert_eq!(s3.users.len(), 2); 168 | 169 | s1.flush().await; 170 | assert_eq!(s1.users.len(), 2); 171 | 172 | Ok(()) 173 | } 174 | 175 | #[tokio::test] 176 | async fn test_users_metadata() -> Result<()> { 177 | let server = TestServer::new().await; 178 | 179 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 180 | let name = controller.name().to_owned(); 181 | let key = controller.encryption_key().to_owned(); 182 | tokio::spawn(async move { controller.run().await }); 183 | 184 | let endpoint = server.ws_endpoint(&name); 185 | let mut s = ClientSocket::connect(&endpoint, &key, None).await?; 186 | s.flush().await; 187 | assert_eq!(s.users.len(), 1); 188 | assert_eq!(s.users.get(&s.user_id).unwrap().cursor, None); 189 | 190 | s.send(WsClient::SetName("mr. foo".into())).await; 191 | s.send(WsClient::SetCursor(Some((40, 524)))).await; 192 | s.flush().await; 193 | let user = s.users.get(&s.user_id).unwrap(); 194 | assert_eq!(user.name, "mr. foo"); 195 | assert_eq!(user.cursor, Some((40, 524))); 196 | 197 | Ok(()) 198 | } 199 | 200 | #[tokio::test] 201 | async fn test_chat_messages() -> Result<()> { 202 | let server = TestServer::new().await; 203 | 204 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; 205 | let name = controller.name().to_owned(); 206 | let key = controller.encryption_key().to_owned(); 207 | tokio::spawn(async move { controller.run().await }); 208 | 209 | let endpoint = server.ws_endpoint(&name); 210 | let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?; 211 | let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?; 212 | 213 | s1.send(WsClient::SetName("billy".into())).await; 214 | s1.send(WsClient::Chat("hello there!".into())).await; 215 | s1.flush().await; 216 | 217 | s2.flush().await; 218 | assert_eq!(s2.messages.len(), 1); 219 | assert_eq!( 220 | s2.messages[0], 221 | (s1.user_id, "billy".into(), "hello there!".into()) 222 | ); 223 | 224 | let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?; 225 | s3.flush().await; 226 | assert_eq!(s1.messages.len(), 1); 227 | assert_eq!(s3.messages.len(), 0); 228 | 229 | Ok(()) 230 | } 231 | 232 | #[tokio::test] 233 | async fn test_read_write_permissions() -> Result<()> { 234 | let server = TestServer::new().await; 235 | 236 | // create controller with read-only mode enabled 237 | let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, true).await?; 238 | let name = controller.name().to_owned(); 239 | let key = controller.encryption_key().to_owned(); 240 | let write_url = controller 241 | .write_url() 242 | .expect("Should have write URL when enable_readers is true") 243 | .to_string(); 244 | 245 | tokio::spawn(async move { controller.run().await }); 246 | 247 | let write_password = write_url 248 | .split(',') 249 | .nth(1) 250 | .expect("Write URL should contain password"); 251 | 252 | // connect with write access 253 | let mut writer = 254 | ClientSocket::connect(&server.ws_endpoint(&name), &key, Some(write_password)).await?; 255 | writer.flush().await; 256 | 257 | // test write permissions 258 | writer.send(WsClient::Create(0, 0)).await; 259 | writer.flush().await; 260 | assert_eq!( 261 | writer.shells.len(), 262 | 1, 263 | "Writer should be able to create a shell" 264 | ); 265 | assert!(writer.errors.is_empty(), "Writer should not receive errors"); 266 | 267 | // connect with read-only access 268 | let mut reader = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; 269 | reader.flush().await; 270 | 271 | // test read-only restrictions 272 | reader.send(WsClient::Create(0, 0)).await; 273 | reader.flush().await; 274 | assert!( 275 | !reader.errors.is_empty(), 276 | "Reader should receive an error when attempting to create shell" 277 | ); 278 | assert_eq!( 279 | reader.shells.len(), 280 | 1, 281 | "Reader should still see the existing shell" 282 | ); 283 | 284 | Ok(()) 285 | } 286 | -------------------------------------------------------------------------------- /crates/sshx/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sshx" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | description.workspace = true 7 | repository.workspace = true 8 | documentation.workspace = true 9 | keywords.workspace = true 10 | edition = "2021" 11 | 12 | [dependencies] 13 | aes = "0.8.3" 14 | ansi_term = "0.12.1" 15 | anyhow.workspace = true 16 | argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] } 17 | cfg-if = "1.0.0" 18 | clap.workspace = true 19 | ctr = "0.9.2" 20 | encoding_rs = "0.8.31" 21 | pin-project = "1.1.3" 22 | sshx-core.workspace = true 23 | tokio.workspace = true 24 | tokio-stream.workspace = true 25 | tonic.workspace = true 26 | tracing.workspace = true 27 | tracing-subscriber.workspace = true 28 | whoami = { version = "1.5.1", default-features = false } 29 | 30 | [target.'cfg(unix)'.dependencies] 31 | close_fds = "0.3.2" 32 | nix = { version = "0.27.1", features = ["ioctl", "process", "signal", "term"] } 33 | 34 | [target.'cfg(windows)'.dependencies] 35 | conpty = "0.7.0" 36 | -------------------------------------------------------------------------------- /crates/sshx/examples/stdin_client.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::sync::Arc; 3 | use std::thread; 4 | 5 | use anyhow::Result; 6 | use sshx::terminal::{get_default_shell, Terminal}; 7 | use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; 8 | use tokio::signal; 9 | use tokio::sync::mpsc; 10 | use tracing::{error, info, trace}; 11 | 12 | #[tokio::main] 13 | async fn main() -> Result<()> { 14 | tracing_subscriber::fmt::init(); 15 | 16 | let shell = get_default_shell().await; 17 | info!(%shell, "using default shell"); 18 | 19 | let mut terminal = Terminal::new(&shell).await?; 20 | 21 | // Separate thread for reading from standard input. 22 | let (tx, mut rx) = mpsc::channel::>(16); 23 | thread::spawn(move || loop { 24 | let mut buf = [0u8; 256]; 25 | let n = std::io::stdin().read(&mut buf).unwrap(); 26 | if tx.blocking_send(buf[0..n].into()).is_err() { 27 | break; 28 | } 29 | }); 30 | 31 | let exit_signal = signal::ctrl_c(); 32 | tokio::pin!(exit_signal); 33 | 34 | loop { 35 | let mut buf = [0u8; 256]; 36 | 37 | tokio::select! { 38 | Some(bytes) = rx.recv() => { 39 | terminal.write_all(&bytes).await?; 40 | } 41 | result = terminal.read(&mut buf) => { 42 | let n = result?; 43 | io::stdout().write_all(&buf[..n]).await?; 44 | } 45 | result = &mut exit_signal => { 46 | if let Err(err) = result { 47 | error!(?err, "failed to listen for exit signal"); 48 | } 49 | trace!("gracefully exiting main"); 50 | break; 51 | } 52 | } 53 | } 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /crates/sshx/src/encrypt.rs: -------------------------------------------------------------------------------- 1 | //! Encryption of byte streams based on a random key. 2 | 3 | use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; 4 | 5 | type Aes128Ctr64BE = ctr::Ctr64BE; 6 | 7 | // Note: The KDF salt is public, as it needs to be used from the web client. It 8 | // only exists to make rainbow table attacks less likely. 9 | const SALT: &str = 10 | "This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!"; 11 | 12 | /// Encrypts byte streams using the Argon2 hash of a random key. 13 | #[derive(Clone)] 14 | pub struct Encrypt { 15 | aes_key: [u8; 16], // 16-bit 16 | } 17 | 18 | impl Encrypt { 19 | /// Construct a new encryptor. 20 | pub fn new(key: &str) -> Self { 21 | use argon2::{Algorithm, Argon2, Params, Version}; 22 | // These parameters must match the browser implementation. 23 | let hasher = Argon2::new( 24 | Algorithm::Argon2id, 25 | Version::V0x13, 26 | Params::new(19 * 1024, 2, 1, Some(16)).unwrap(), 27 | ); 28 | let mut aes_key = [0; 16]; 29 | hasher 30 | .hash_password_into(key.as_bytes(), SALT.as_bytes(), &mut aes_key) 31 | .expect("failed to hash key with argon2"); 32 | Self { aes_key } 33 | } 34 | 35 | /// Get the encrypted zero block. 36 | pub fn zeros(&self) -> Vec { 37 | let mut zeros = [0; 16]; 38 | let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &zeros.into()); 39 | cipher.apply_keystream(&mut zeros); 40 | zeros.to_vec() 41 | } 42 | 43 | /// Encrypt a segment of data from a stream. 44 | /// 45 | /// Note that in CTR mode, the encryption operation is the same as the 46 | /// decryption operation. 47 | pub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Vec { 48 | assert_ne!(stream_num, 0, "stream number must be nonzero"); // security check 49 | 50 | let mut iv = [0; 16]; 51 | iv[0..8].copy_from_slice(&stream_num.to_be_bytes()); 52 | 53 | let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &iv.into()); 54 | let mut buf = data.to_vec(); 55 | cipher.seek(offset); 56 | cipher.apply_keystream(&mut buf); 57 | buf 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::Encrypt; 64 | 65 | #[test] 66 | fn make_encrypt() { 67 | let encrypt = Encrypt::new("test"); 68 | assert_eq!( 69 | encrypt.zeros(), 70 | [198, 3, 249, 238, 65, 10, 224, 98, 253, 73, 148, 1, 138, 3, 108, 143], 71 | ); 72 | } 73 | 74 | #[test] 75 | fn roundtrip_ctr() { 76 | let encrypt = Encrypt::new("this is a test key"); 77 | let data = b"hello world"; 78 | let encrypted = encrypt.segment(1, 0, data); 79 | assert_eq!(encrypted.len(), data.len()); 80 | let decrypted = encrypt.segment(1, 0, &encrypted); 81 | assert_eq!(decrypted, data); 82 | } 83 | 84 | #[test] 85 | fn matches_offset() { 86 | let encrypt = Encrypt::new("this is a test key"); 87 | let data = b"1st block.(16B)|2nd block......|3rd block"; 88 | let encrypted = encrypt.segment(1, 0, data); 89 | assert_eq!(encrypted.len(), data.len()); 90 | for i in 1..data.len() { 91 | let encrypted_suffix = encrypt.segment(1, i as u64, &data[i..]); 92 | assert_eq!(encrypted_suffix, &encrypted[i..]); 93 | } 94 | } 95 | 96 | #[test] 97 | #[should_panic] 98 | fn zero_stream_num() { 99 | let encrypt = Encrypt::new("this is a test key"); 100 | encrypt.segment(0, 0, b"hello world"); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /crates/sshx/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Library code for the sshx command-line client application. 2 | //! 3 | //! This crate does not forbid use of unsafe code because it needs to interact 4 | //! with operating-system APIs to access pseudoterminal (PTY) devices. 5 | 6 | #![deny(unsafe_code)] 7 | #![warn(missing_docs)] 8 | 9 | pub mod controller; 10 | pub mod encrypt; 11 | pub mod runner; 12 | pub mod terminal; 13 | -------------------------------------------------------------------------------- /crates/sshx/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | 3 | use ansi_term::Color::{Cyan, Fixed, Green}; 4 | use anyhow::Result; 5 | use clap::Parser; 6 | use sshx::{controller::Controller, runner::Runner, terminal::get_default_shell}; 7 | use tokio::signal; 8 | use tracing::error; 9 | 10 | /// A secure web-based, collaborative terminal. 11 | #[derive(Parser, Debug)] 12 | #[clap(author, version, about, long_about = None)] 13 | struct Args { 14 | /// Address of the remote sshx server. 15 | #[clap(long, default_value = "https://sshx.io", env = "SSHX_SERVER")] 16 | server: String, 17 | 18 | /// Local shell command to run in the terminal. 19 | #[clap(long)] 20 | shell: Option, 21 | 22 | /// Quiet mode, only prints the URL to stdout. 23 | #[clap(short, long)] 24 | quiet: bool, 25 | 26 | /// Session name displayed in the title (defaults to user@hostname). 27 | #[clap(long)] 28 | name: Option, 29 | 30 | /// Enable read-only access mode - generates separate URLs for viewers and 31 | /// editors. 32 | #[clap(long)] 33 | enable_readers: bool, 34 | } 35 | 36 | fn print_greeting(shell: &str, controller: &Controller) { 37 | let version_str = match option_env!("CARGO_PKG_VERSION") { 38 | Some(version) => format!("v{version}"), 39 | None => String::from("[dev]"), 40 | }; 41 | if let Some(write_url) = controller.write_url() { 42 | println!( 43 | r#" 44 | {sshx} {version} 45 | 46 | {arr} Read-only link: {link_v} 47 | {arr} Writable link: {link_e} 48 | {arr} Shell: {shell_v} 49 | "#, 50 | sshx = Green.bold().paint("sshx"), 51 | version = Green.paint(&version_str), 52 | arr = Green.paint("➜"), 53 | link_v = Cyan.underline().paint(controller.url()), 54 | link_e = Cyan.underline().paint(write_url), 55 | shell_v = Fixed(8).paint(shell), 56 | ); 57 | } else { 58 | println!( 59 | r#" 60 | {sshx} {version} 61 | 62 | {arr} Link: {link_v} 63 | {arr} Shell: {shell_v} 64 | "#, 65 | sshx = Green.bold().paint("sshx"), 66 | version = Green.paint(&version_str), 67 | arr = Green.paint("➜"), 68 | link_v = Cyan.underline().paint(controller.url()), 69 | shell_v = Fixed(8).paint(shell), 70 | ); 71 | } 72 | } 73 | 74 | #[tokio::main] 75 | async fn start(args: Args) -> Result<()> { 76 | let shell = match args.shell { 77 | Some(shell) => shell, 78 | None => get_default_shell().await, 79 | }; 80 | 81 | let name = args.name.unwrap_or_else(|| { 82 | let mut name = whoami::username(); 83 | if let Ok(host) = whoami::fallible::hostname() { 84 | // Trim domain information like .lan or .local 85 | let host = host.split('.').next().unwrap_or(&host); 86 | name += "@"; 87 | name += host; 88 | } 89 | name 90 | }); 91 | 92 | let runner = Runner::Shell(shell.clone()); 93 | let mut controller = Controller::new(&args.server, &name, runner, args.enable_readers).await?; 94 | if args.quiet { 95 | if let Some(write_url) = controller.write_url() { 96 | println!("{}", write_url); 97 | } else { 98 | println!("{}", controller.url()); 99 | } 100 | } else { 101 | print_greeting(&shell, &controller); 102 | } 103 | 104 | let exit_signal = signal::ctrl_c(); 105 | tokio::pin!(exit_signal); 106 | tokio::select! { 107 | _ = controller.run() => unreachable!(), 108 | Ok(()) = &mut exit_signal => (), 109 | }; 110 | controller.close().await?; 111 | 112 | Ok(()) 113 | } 114 | 115 | fn main() -> ExitCode { 116 | let args = Args::parse(); 117 | 118 | let default_level = if args.quiet { "error" } else { "info" }; 119 | 120 | tracing_subscriber::fmt() 121 | .with_env_filter(std::env::var("RUST_LOG").unwrap_or(default_level.into())) 122 | .with_writer(std::io::stderr) 123 | .init(); 124 | 125 | match start(args) { 126 | Ok(()) => ExitCode::SUCCESS, 127 | Err(err) => { 128 | error!("{err:?}"); 129 | ExitCode::FAILURE 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /crates/sshx/src/runner.rs: -------------------------------------------------------------------------------- 1 | //! Defines tasks that control the behavior of a single shell in the client. 2 | 3 | use anyhow::Result; 4 | use encoding_rs::{CoderResult, UTF_8}; 5 | use sshx_core::proto::{client_update::ClientMessage, TerminalData}; 6 | use sshx_core::Sid; 7 | use tokio::{ 8 | io::{AsyncReadExt, AsyncWriteExt}, 9 | sync::mpsc, 10 | }; 11 | 12 | use crate::encrypt::Encrypt; 13 | use crate::terminal::Terminal; 14 | 15 | const CONTENT_CHUNK_SIZE: usize = 1 << 16; // Send at most this many bytes at a time. 16 | const CONTENT_ROLLING_BYTES: usize = 8 << 20; // Store at least this much content. 17 | const CONTENT_PRUNE_BYTES: usize = 12 << 20; // Prune when we exceed this length. 18 | 19 | /// Variants of terminal behavior that are used by the controller. 20 | #[derive(Debug, Clone)] 21 | pub enum Runner { 22 | /// Spawns the specified shell as a subprocess, forwarding PTYs. 23 | Shell(String), 24 | 25 | /// Mock runner that only echos its input, useful for testing. 26 | Echo, 27 | } 28 | 29 | /// Internal message routed to shell runners. 30 | pub enum ShellData { 31 | /// Sequence of input bytes from the server. 32 | Data(Vec), 33 | /// Information about the server's current sequence number. 34 | Sync(u64), 35 | /// Resize the shell to a different number of rows and columns. 36 | Size(u32, u32), 37 | } 38 | 39 | impl Runner { 40 | /// Asynchronous task to run a single shell with process I/O. 41 | pub async fn run( 42 | &self, 43 | id: Sid, 44 | encrypt: Encrypt, 45 | shell_rx: mpsc::Receiver, 46 | output_tx: mpsc::Sender, 47 | ) -> Result<()> { 48 | match self { 49 | Self::Shell(shell) => shell_task(id, encrypt, shell, shell_rx, output_tx).await, 50 | Self::Echo => echo_task(id, encrypt, shell_rx, output_tx).await, 51 | } 52 | } 53 | } 54 | 55 | /// Asynchronous task handling a single shell within the session. 56 | async fn shell_task( 57 | id: Sid, 58 | encrypt: Encrypt, 59 | shell: &str, 60 | mut shell_rx: mpsc::Receiver, 61 | output_tx: mpsc::Sender, 62 | ) -> Result<()> { 63 | let mut term = Terminal::new(shell).await?; 64 | term.set_winsize(24, 80)?; 65 | 66 | let mut content = String::new(); // content from the terminal 67 | let mut content_offset = 0; // bytes before the first character of `content` 68 | let mut decoder = UTF_8.new_decoder(); // UTF-8 streaming decoder 69 | let mut seq = 0; // our log of the server's sequence number 70 | let mut seq_outdated = 0; // number of times seq has been outdated 71 | let mut buf = [0u8; 4096]; // buffer for reading 72 | let mut finished = false; // set when this is done 73 | 74 | while !finished { 75 | tokio::select! { 76 | result = term.read(&mut buf) => { 77 | let n = result?; 78 | if n == 0 { 79 | finished = true; 80 | } else { 81 | content.reserve(decoder.max_utf8_buffer_length(n).unwrap()); 82 | let (result, _, _) = decoder.decode_to_string(&buf[..n], &mut content, false); 83 | debug_assert!(result == CoderResult::InputEmpty); 84 | } 85 | } 86 | item = shell_rx.recv() => { 87 | match item { 88 | Some(ShellData::Data(data)) => { 89 | term.write_all(&data).await?; 90 | } 91 | Some(ShellData::Sync(seq2)) => { 92 | if seq2 < seq as u64 { 93 | seq_outdated += 1; 94 | if seq_outdated >= 3 { 95 | seq = seq2 as usize; 96 | } 97 | } 98 | } 99 | Some(ShellData::Size(rows, cols)) => { 100 | term.set_winsize(rows as u16, cols as u16)?; 101 | } 102 | None => finished = true, // Server closed this shell. 103 | } 104 | } 105 | } 106 | 107 | if finished { 108 | content.reserve(decoder.max_utf8_buffer_length(0).unwrap()); 109 | let (result, _, _) = decoder.decode_to_string(&[], &mut content, true); 110 | debug_assert!(result == CoderResult::InputEmpty); 111 | } 112 | 113 | // Send data if the server has fallen behind. 114 | if content_offset + content.len() > seq { 115 | let start = prev_char_boundary(&content, seq - content_offset); 116 | let end = prev_char_boundary(&content, (start + CONTENT_CHUNK_SIZE).min(content.len())); 117 | let data = encrypt.segment( 118 | 0x100000000 | id.0 as u64, // stream number 119 | (content_offset + start) as u64, 120 | content[start..end].as_bytes(), 121 | ); 122 | let data = TerminalData { 123 | id: id.0, 124 | data: data.into(), 125 | seq: (content_offset + start) as u64, 126 | }; 127 | output_tx.send(ClientMessage::Data(data)).await?; 128 | seq = content_offset + end; 129 | seq_outdated = 0; 130 | } 131 | 132 | if content.len() > CONTENT_PRUNE_BYTES && seq - CONTENT_ROLLING_BYTES > content_offset { 133 | let pruned = (seq - CONTENT_ROLLING_BYTES) - content_offset; 134 | let pruned = prev_char_boundary(&content, pruned); 135 | content_offset += pruned; 136 | content.drain(..pruned); 137 | } 138 | } 139 | Ok(()) 140 | } 141 | 142 | /// Find the last char boundary before an index in O(1) time. 143 | fn prev_char_boundary(s: &str, i: usize) -> usize { 144 | (0..=i) 145 | .rev() 146 | .find(|&j| s.is_char_boundary(j)) 147 | .expect("no previous char boundary") 148 | } 149 | 150 | async fn echo_task( 151 | id: Sid, 152 | encrypt: Encrypt, 153 | mut shell_rx: mpsc::Receiver, 154 | output_tx: mpsc::Sender, 155 | ) -> Result<()> { 156 | let mut seq = 0; 157 | while let Some(item) = shell_rx.recv().await { 158 | match item { 159 | ShellData::Data(data) => { 160 | let msg = String::from_utf8_lossy(&data); 161 | let term_data = TerminalData { 162 | id: id.0, 163 | data: encrypt 164 | .segment(0x100000000 | id.0 as u64, seq, msg.as_bytes()) 165 | .into(), 166 | seq, 167 | }; 168 | output_tx.send(ClientMessage::Data(term_data)).await?; 169 | seq += msg.len() as u64; 170 | } 171 | ShellData::Sync(_) => (), 172 | ShellData::Size(_, _) => (), 173 | } 174 | } 175 | Ok(()) 176 | } 177 | -------------------------------------------------------------------------------- /crates/sshx/src/terminal.rs: -------------------------------------------------------------------------------- 1 | //! Terminal driver, which communicates with a shell subprocess through PTY. 2 | 3 | #![allow(unsafe_code)] 4 | 5 | cfg_if::cfg_if! { 6 | if #[cfg(unix)] { 7 | mod unix; 8 | pub use unix::{get_default_shell, Terminal}; 9 | } else if #[cfg(windows)] { 10 | mod windows; 11 | pub use windows::{get_default_shell, Terminal}; 12 | } else { 13 | compile_error!("unsupported platform for terminal driver"); 14 | } 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use anyhow::Result; 20 | 21 | use super::Terminal; 22 | 23 | #[tokio::test] 24 | async fn winsize() -> Result<()> { 25 | let shell = if cfg!(unix) { "/bin/sh" } else { "cmd.exe" }; 26 | let mut terminal = Terminal::new(shell).await?; 27 | assert_eq!(terminal.get_winsize()?, (0, 0)); 28 | terminal.set_winsize(120, 72)?; 29 | assert_eq!(terminal.get_winsize()?, (120, 72)); 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/sshx/src/terminal/unix.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::env; 3 | use std::ffi::{CStr, CString}; 4 | use std::os::fd::{AsRawFd, RawFd}; 5 | use std::pin::Pin; 6 | use std::task::{Context, Poll}; 7 | 8 | use anyhow::Result; 9 | use close_fds::CloseFdsBuilder; 10 | use nix::errno::Errno; 11 | use nix::libc::{login_tty, TIOCGWINSZ, TIOCSWINSZ}; 12 | use nix::pty::{self, Winsize}; 13 | use nix::sys::signal::{kill, Signal::SIGKILL}; 14 | use nix::sys::wait::waitpid; 15 | use nix::unistd::{execvp, fork, ForkResult, Pid}; 16 | use pin_project::{pin_project, pinned_drop}; 17 | use tokio::fs::{self, File}; 18 | use tokio::io::{self, AsyncRead, AsyncWrite}; 19 | use tracing::{instrument, trace}; 20 | 21 | /// Returns the default shell on this system. 22 | pub async fn get_default_shell() -> String { 23 | if let Ok(shell) = env::var("SHELL") { 24 | if !shell.is_empty() { 25 | return shell; 26 | } 27 | } 28 | for shell in [ 29 | "/bin/bash", 30 | "/bin/sh", 31 | "/usr/local/bin/bash", 32 | "/usr/local/bin/sh", 33 | ] { 34 | if fs::metadata(shell).await.is_ok() { 35 | return shell.to_string(); 36 | } 37 | } 38 | String::from("sh") 39 | } 40 | 41 | /// An object that stores the state for a terminal session. 42 | #[pin_project(PinnedDrop)] 43 | pub struct Terminal { 44 | child: Pid, 45 | #[pin] 46 | master_read: File, 47 | #[pin] 48 | master_write: File, 49 | } 50 | 51 | impl Terminal { 52 | /// Create a new terminal, with attached PTY. 53 | #[instrument] 54 | pub async fn new(shell: &str) -> Result { 55 | let result = pty::openpty(None, None)?; 56 | 57 | // The slave file descriptor was created by openpty() and is forked here. 58 | let child = Self::fork_child(shell, result.slave.as_raw_fd())?; 59 | 60 | // We need to clone the file object to prevent livelocks in Tokio, when multiple 61 | // reads and writes happen concurrently on the same file descriptor. This is a 62 | // current limitation of how the `tokio::fs::File` struct is implemented, due to 63 | // its blocking I/O on a separate thread. 64 | let master_read = File::from(std::fs::File::from(result.master)); 65 | let master_write = master_read.try_clone().await?; 66 | 67 | trace!(%child, "creating new terminal"); 68 | 69 | Ok(Self { 70 | child, 71 | master_read, 72 | master_write, 73 | }) 74 | } 75 | 76 | /// Entry point for the child process, which spawns a shell. 77 | fn fork_child(shell: &str, slave_port: RawFd) -> Result { 78 | let shell = CString::new(shell.to_owned())?; 79 | 80 | // Safety: This does not use any async-signal-unsafe operations in the child 81 | // branch, such as memory allocation. 82 | match unsafe { fork() }? { 83 | ForkResult::Parent { child } => Ok(child), 84 | ForkResult::Child => match Self::execv_child(&shell, slave_port) { 85 | Ok(infallible) => match infallible {}, 86 | Err(_) => std::process::exit(1), 87 | }, 88 | } 89 | } 90 | 91 | fn execv_child(shell: &CStr, slave_port: RawFd) -> Result { 92 | // Safety: The slave file descriptor was created by openpty(). 93 | Errno::result(unsafe { login_tty(slave_port) })?; 94 | // Safety: This is called immediately before an execv(), and there are no other 95 | // threads in this process to interact with its file descriptor table. 96 | unsafe { CloseFdsBuilder::new().closefrom(3) }; 97 | 98 | // Set terminal environment variables appropriately. 99 | env::set_var("TERM", "xterm-256color"); 100 | env::set_var("COLORTERM", "truecolor"); 101 | env::set_var("TERM_PROGRAM", "sshx"); 102 | env::remove_var("TERM_PROGRAM_VERSION"); 103 | 104 | // Start the process. 105 | execvp(shell, &[shell]) 106 | } 107 | 108 | /// Get the window size of the TTY. 109 | pub fn get_winsize(&self) -> Result<(u16, u16)> { 110 | nix::ioctl_read_bad!(ioctl_get_winsize, TIOCGWINSZ, Winsize); 111 | let mut winsize = make_winsize(0, 0); 112 | // Safety: The master file descriptor was created by openpty(). 113 | unsafe { ioctl_get_winsize(self.master_read.as_raw_fd(), &mut winsize) }?; 114 | Ok((winsize.ws_row, winsize.ws_col)) 115 | } 116 | 117 | /// Set the window size of the TTY. 118 | pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> { 119 | nix::ioctl_write_ptr_bad!(ioctl_set_winsize, TIOCSWINSZ, Winsize); 120 | let winsize = make_winsize(rows, cols); 121 | // Safety: The master file descriptor was created by openpty(). 122 | unsafe { ioctl_set_winsize(self.master_read.as_raw_fd(), &winsize) }?; 123 | Ok(()) 124 | } 125 | } 126 | 127 | // Redirect terminal reads to the read file object. 128 | impl AsyncRead for Terminal { 129 | fn poll_read( 130 | self: Pin<&mut Self>, 131 | cx: &mut Context<'_>, 132 | buf: &mut io::ReadBuf<'_>, 133 | ) -> Poll> { 134 | self.project().master_read.poll_read(cx, buf) 135 | } 136 | } 137 | 138 | // Redirect terminal writes to the write file object. 139 | impl AsyncWrite for Terminal { 140 | fn poll_write( 141 | self: Pin<&mut Self>, 142 | cx: &mut Context<'_>, 143 | buf: &[u8], 144 | ) -> Poll> { 145 | self.project().master_write.poll_write(cx, buf) 146 | } 147 | 148 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 149 | self.project().master_write.poll_flush(cx) 150 | } 151 | 152 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 153 | self.project().master_write.poll_shutdown(cx) 154 | } 155 | } 156 | 157 | #[pinned_drop] 158 | impl PinnedDrop for Terminal { 159 | fn drop(self: Pin<&mut Self>) { 160 | let this = self.project(); 161 | let child = *this.child; 162 | trace!(%child, "dropping terminal"); 163 | 164 | // Kill the child process on closure so that it doesn't keep running. 165 | kill(child, SIGKILL).ok(); 166 | 167 | // Reap the zombie process in a background thread. 168 | std::thread::spawn(move || { 169 | waitpid(child, None).ok(); 170 | }); 171 | } 172 | } 173 | 174 | fn make_winsize(rows: u16, cols: u16) -> Winsize { 175 | Winsize { 176 | ws_row: rows, 177 | ws_col: cols, 178 | ws_xpixel: 0, // ignored 179 | ws_ypixel: 0, // ignored 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /crates/sshx/src/terminal/windows.rs: -------------------------------------------------------------------------------- 1 | use std::pin::Pin; 2 | use std::process::Command; 3 | use std::task::Context; 4 | use std::task::Poll; 5 | 6 | use anyhow::Result; 7 | use pin_project::{pin_project, pinned_drop}; 8 | use tokio::fs::{self, File}; 9 | use tokio::io::{self, AsyncRead, AsyncWrite}; 10 | use tracing::instrument; 11 | 12 | /// Returns the default shell on this system. 13 | /// 14 | /// For Windows, this is implemented currently to just look for shells at a 15 | /// couple locations. If it fails, it returns `cmd.exe`. 16 | /// 17 | /// Note: I can't get `powershell.exe` to work with ConPTY, since it returns 18 | /// error 8009001d. There's some magic environment variables that need to be set 19 | /// for Powershell to launch. This is why I don't typically use Windows! 20 | pub async fn get_default_shell() -> String { 21 | for shell in [ 22 | "C:\\Program Files\\Git\\bin\\bash.exe", 23 | "C:\\Windows\\System32\\cmd.exe", 24 | ] { 25 | if fs::metadata(shell).await.is_ok() { 26 | return shell.to_string(); 27 | } 28 | } 29 | String::from("cmd.exe") 30 | } 31 | 32 | /// An object that stores the state for a terminal session. 33 | #[pin_project(PinnedDrop)] 34 | pub struct Terminal { 35 | child: conpty::Process, 36 | #[pin] 37 | reader: File, 38 | #[pin] 39 | writer: File, 40 | winsize: (u16, u16), 41 | } 42 | 43 | impl Terminal { 44 | /// Create a new terminal, with attached PTY. 45 | #[instrument] 46 | pub async fn new(shell: &str) -> Result { 47 | let mut command = Command::new(shell); 48 | 49 | // Set terminal environment variables appropriately. 50 | command.env("TERM", "xterm-256color"); 51 | command.env("COLORTERM", "truecolor"); 52 | command.env("TERM_PROGRAM", "sshx"); 53 | command.env_remove("TERM_PROGRAM_VERSION"); 54 | 55 | let mut child = 56 | tokio::task::spawn_blocking(move || conpty::Process::spawn(command)).await??; 57 | let reader = File::from_std(child.output()?.into()); 58 | let writer = File::from_std(child.input()?.into()); 59 | 60 | Ok(Self { 61 | child, 62 | reader, 63 | writer, 64 | winsize: (0, 0), 65 | }) 66 | } 67 | 68 | /// Get the window size of the TTY. 69 | pub fn get_winsize(&self) -> Result<(u16, u16)> { 70 | Ok(self.winsize) 71 | } 72 | 73 | /// Set the window size of the TTY. 74 | pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> { 75 | let rows_i16 = rows.min(i16::MAX as u16) as i16; 76 | let cols_i16 = cols.min(i16::MAX as u16) as i16; 77 | self.child.resize(cols_i16, rows_i16)?; // Note argument order 78 | self.winsize = (rows, cols); 79 | Ok(()) 80 | } 81 | } 82 | 83 | // Redirect terminal reads to the read file object. 84 | impl AsyncRead for Terminal { 85 | fn poll_read( 86 | self: Pin<&mut Self>, 87 | cx: &mut Context<'_>, 88 | buf: &mut io::ReadBuf<'_>, 89 | ) -> Poll> { 90 | self.project().reader.poll_read(cx, buf) 91 | } 92 | } 93 | 94 | // Redirect terminal writes to the write file object. 95 | impl AsyncWrite for Terminal { 96 | fn poll_write( 97 | self: Pin<&mut Self>, 98 | cx: &mut Context<'_>, 99 | buf: &[u8], 100 | ) -> Poll> { 101 | self.project().writer.poll_write(cx, buf) 102 | } 103 | 104 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 105 | self.project().writer.poll_flush(cx) 106 | } 107 | 108 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 109 | self.project().writer.poll_shutdown(cx) 110 | } 111 | } 112 | 113 | #[pinned_drop] 114 | impl PinnedDrop for Terminal { 115 | fn drop(self: Pin<&mut Self>) { 116 | let this = self.project(); 117 | this.child.exit(0).ok(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "sshx" 2 | primary_region = "ewr" 3 | kill_signal = "SIGINT" 4 | kill_timeout = 90 5 | 6 | [experimental] 7 | auto_rollback = true 8 | cmd = ["sh", "-c", "./sshx-server --listen :: --host \"$FLY_ALLOC_ID.vm.sshx.internal:8051\""] 9 | 10 | [[services]] 11 | protocol = "tcp" 12 | internal_port = 8051 13 | processes = ["app"] 14 | 15 | [services.concurrency] 16 | type = "connections" 17 | hard_limit = 65536 18 | soft_limit = 1024 19 | 20 | [[services.ports]] 21 | port = 80 22 | handlers = ["http"] 23 | force_https = true 24 | 25 | [[services.ports]] 26 | port = 443 27 | handlers = ["tls"] 28 | [services.ports.tls_options] 29 | alpn = ["h2", "http/1.1"] 30 | 31 | [[services.tcp_checks]] 32 | interval = "15s" 33 | timeout = "2s" 34 | grace_period = "1s" 35 | restart_limit = 0 36 | -------------------------------------------------------------------------------- /mprocs.yaml: -------------------------------------------------------------------------------- 1 | # prettier-ignore 2 | procs: 3 | server: 4 | shell: >- 5 | cargo run --bin sshx-server -- 6 | --override-origin http://localhost:5173 7 | --secret dev-secret 8 | --redis-url redis://localhost:12601 9 | client: 10 | shell: >- 11 | cargo run --bin sshx -- 12 | --server http://localhost:8051 13 | web: 14 | shell: npm run dev 15 | stop: SIGKILL # TODO: Why is this necessary? 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshx", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", 12 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." 13 | }, 14 | "dependencies": { 15 | "@fontsource-variable/inter": "^5.0.8", 16 | "@rgossiaux/svelte-headlessui": "^2.0.0", 17 | "@tldraw/vec": "^1.9.2", 18 | "@use-gesture/vanilla": "^10.2.27", 19 | "argon2-browser": "^1.18.0", 20 | "buffer": "^6.0.3", 21 | "cbor-x": "^1.6.0", 22 | "firacode": "^6.2.0", 23 | "fontfaceobserver": "^2.3.0", 24 | "lodash-es": "^4.17.21", 25 | "perfect-cursors": "^1.0.5", 26 | "sshx-xterm": "5.2.1", 27 | "svelte": "^3.59.2", 28 | "svelte-feather-icons": "^4.0.1", 29 | "svelte-persisted-store": "^0.7.0", 30 | "xterm-addon-image": "^0.5.0", 31 | "xterm-addon-web-links": "^0.9.0", 32 | "xterm-addon-webgl": "^0.16.0" 33 | }, 34 | "devDependencies": { 35 | "@sveltejs/adapter-static": "^2.0.3", 36 | "@sveltejs/kit": "^1.24.1", 37 | "@types/lodash-es": "^4.17.9", 38 | "@typescript-eslint/eslint-plugin": "^6.7.0", 39 | "@typescript-eslint/parser": "^6.7.0", 40 | "autoprefixer": "^10.4.15", 41 | "cssnano": "^6.0.1", 42 | "eslint": "^8.49.0", 43 | "eslint-config-prettier": "^9.0.0", 44 | "eslint-plugin-svelte3": "^4.0.0", 45 | "postcss": "^8.4.29", 46 | "postcss-load-config": "^4.0.1", 47 | "prettier": "2.8.8", 48 | "prettier-plugin-svelte": "2.10.1", 49 | "svelte-check": "^3.5.1", 50 | "svelte-preprocess": "^5.0.4", 51 | "tailwindcss": "^3.3.3", 52 | "tslib": "^2.6.2", 53 | "typescript": "~5.2.2", 54 | "vite": "^4.4.9" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | const autoprefixer = require("autoprefixer"); 3 | const cssnano = require("cssnano"); 4 | 5 | const mode = process.env.NODE_ENV; 6 | const dev = mode === "development"; 7 | 8 | const config = { 9 | plugins: [ 10 | // Some plugins, like tailwindcss/nesting, need to run before Tailwind, 11 | tailwindcss(), 12 | // But others, like autoprefixer, need to run after, 13 | autoprefixer(), 14 | !dev && cssnano({ preset: "default" }), 15 | ], 16 | }; 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | group_imports = "StdExternalCrate" 3 | wrap_comments = true 4 | format_strings = true 5 | normalize_comments = true 6 | reorder_impl_items = true 7 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Manually releases the latest binaries to AWS S3. 4 | # 5 | # This runs on my M1 Macbook Pro with cross-compilation toolchains. I think it's 6 | # probably better to replace this script with a CI configuration later. 7 | 8 | set +e 9 | 10 | # x86_64: for most Linux servers 11 | TARGET_CC=x86_64-unknown-linux-musl-cc \ 12 | CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \ 13 | cargo build --release --target x86_64-unknown-linux-musl 14 | 15 | # aarch64: for newer Linux servers 16 | TARGET_CC=aarch64-unknown-linux-musl-cc \ 17 | CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-unknown-linux-musl-gcc \ 18 | cargo build --release --target aarch64-unknown-linux-musl 19 | 20 | # armv6l: for devices like Raspberry Pi Zero W 21 | TARGET_CC=arm-unknown-linux-musleabihf-cc \ 22 | CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-unknown-linux-musleabihf-gcc \ 23 | cargo build --release --target arm-unknown-linux-musleabihf 24 | 25 | # armv7l: for devices like Oxdroid XU4 26 | TARGET_CC=armv7-unknown-linux-musleabihf-cc \ 27 | CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7-unknown-linux-musleabihf-gcc \ 28 | cargo build --release --target armv7-unknown-linux-musleabihf 29 | 30 | # x86_64-apple-darwin: for macOS on Intel 31 | SDKROOT=$(xcrun -sdk macosx15.2 --show-sdk-path) \ 32 | MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx15.2 --show-sdk-platform-version) \ 33 | cargo build --release --target x86_64-apple-darwin 34 | 35 | # aarch64-apple-darwin: for macOS on Apple Silicon 36 | cargo build --release --target aarch64-apple-darwin 37 | 38 | # x86_64-unknown-freebsd: for FreeBSD 39 | cross build --release --target x86_64-unknown-freebsd 40 | 41 | # *-pc-windows-msvc: for Windows, requires cargo-xwin 42 | XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target x86_64-pc-windows-msvc 43 | XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target i686-pc-windows-msvc 44 | XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target aarch64-pc-windows-msvc --cross-compiler clang 45 | 46 | temp=$(mktemp) 47 | targets=( 48 | x86_64-unknown-linux-musl 49 | aarch64-unknown-linux-musl 50 | arm-unknown-linux-musleabihf 51 | armv7-unknown-linux-musleabihf 52 | x86_64-apple-darwin 53 | aarch64-apple-darwin 54 | x86_64-unknown-freebsd 55 | x86_64-pc-windows-msvc 56 | i686-pc-windows-msvc 57 | aarch64-pc-windows-msvc 58 | ) 59 | for target in "${targets[@]}" 60 | do 61 | if [[ ! $target == *"windows"* ]]; then 62 | echo "compress: target/$target/release/sshx" 63 | tar czf $temp -C target/$target/release sshx 64 | aws s3 cp $temp s3://sshx/sshx-$target.tar.gz 65 | 66 | echo "compress: target/$target/release/sshx-server" 67 | tar czf $temp -C target/$target/release sshx-server 68 | aws s3 cp $temp s3://sshx/sshx-server-$target.tar.gz 69 | else 70 | echo "compress: target/$target/release/sshx.exe" 71 | rm $temp && zip -j $temp target/$target/release/sshx.exe 72 | aws s3 cp $temp s3://sshx/sshx-$target.zip 73 | fi 74 | done 75 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Fira Code VF"; 3 | src: url("firacode/distr/woff2/FiraCode-VF.woff2") format("woff2-variations"), 4 | url("firacode/distr/woff/FiraCode-VF.woff") format("woff-variations"); 5 | /* font-weight requires a range: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide#Using_a_variable_font_font-face_changes */ 6 | font-weight: 300 700; 7 | font-style: normal; 8 | } 9 | 10 | @tailwind base; 11 | @tailwind components; 12 | @tailwind utilities; 13 | 14 | @layer base { 15 | body { 16 | color-scheme: dark; 17 | } 18 | } 19 | 20 | @layer components { 21 | .panel { 22 | @apply border border-zinc-800 bg-zinc-900/90 backdrop-blur-sm rounded-xl pointer-events-auto; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Injected by vite.config.ts 4 | declare const __APP_VERSION__: string; 5 | 6 | // See https://kit.svelte.dev/docs/types#the-app-namespace 7 | // for information about these interfaces 8 | declare namespace App { 9 | // interface Locals {} 10 | // interface Platform {} 11 | // interface Session {} 12 | // interface Stuff {} 13 | } 14 | 15 | // Type declarations for external libraries. 16 | declare module "fontfaceobserver"; 17 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | sshx 12 | 13 | 17 | 21 | 25 | 26 | 27 | %sveltekit.head% 28 | 29 | 30 |
%sveltekit.body%
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/lib/action/slide.ts: -------------------------------------------------------------------------------- 1 | import { tweened } from "svelte/motion"; 2 | import { cubicOut } from "svelte/easing"; 3 | import type { Action } from "svelte/action"; 4 | import { PerfectCursor } from "perfect-cursors"; 5 | 6 | export type SlideParams = { 7 | x: number; 8 | y: number; 9 | center: number[]; 10 | zoom: number; 11 | immediate?: boolean; 12 | }; 13 | 14 | /** An action for tweened transitions with global transformations. */ 15 | export const slide: Action = (node, params) => { 16 | let center = params?.center ?? [0, 0]; 17 | let zoom = params?.zoom ?? 1; 18 | 19 | const pos = { x: params?.x ?? 0, y: params?.y ?? 0 }; 20 | const spos = tweened(pos, { duration: 150, easing: cubicOut }); 21 | 22 | const disposeSub = spos.subscribe((pos) => { 23 | node.style.transform = `scale(${(zoom * 100).toFixed(3)}%) 24 | translate3d(${pos.x - center[0]}px, ${pos.y - center[1]}px, 0)`; 25 | }); 26 | 27 | return { 28 | update(params) { 29 | center = params?.center ?? [0, 0]; 30 | zoom = params?.zoom ?? 1; 31 | const pos = { x: params?.x ?? 0, y: params?.y ?? 0 }; 32 | spos.set(pos, { duration: params.immediate ? 0 : 150 }); 33 | }, 34 | 35 | destroy() { 36 | disposeSub(); 37 | node.style.transform = ""; 38 | }, 39 | }; 40 | }; 41 | 42 | /** 43 | * An action using perfect-cursors to transition an element. 44 | * 45 | * The transitions are really smooth geometrically, but they seem to introduce 46 | * too much noticeable delay. Keeping this function for reference. 47 | */ 48 | export const slideCursor: Action = (node, params) => { 49 | const pos = params ?? { x: 0, y: 0 }; 50 | 51 | const pc = new PerfectCursor(([x, y]: number[]) => { 52 | node.style.transform = `translate3d(${x}px, ${y}px, 0)`; 53 | }); 54 | pc.addPoint([pos.x, pos.y]); 55 | 56 | return { 57 | update(params) { 58 | const pos = params ?? { x: 0, y: 0 }; 59 | pc.addPoint([pos.x, pos.y]); 60 | }, 61 | 62 | destroy() { 63 | pc.dispose(); 64 | node.style.transform = ""; 65 | }, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/lib/action/touchZoom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Handles pan and zoom events to create an infinite canvas. 3 | * 4 | * This file is modified from Dispict , 5 | * which itself is loosely based on tldraw. 6 | */ 7 | 8 | import { 9 | Gesture, 10 | type Handler, 11 | type WebKitGestureEvent, 12 | } from "@use-gesture/vanilla"; 13 | import Vec from "@tldraw/vec"; 14 | 15 | // Credits: from excalidraw 16 | // https://github.com/excalidraw/excalidraw/blob/07ebd7c68ce6ff92ddbc22d1c3d215f2b21328d6/src/utils.ts#L542-L563 17 | const getNearestScrollableContainer = ( 18 | element: HTMLElement, 19 | ): HTMLElement | Document => { 20 | let parent = element.parentElement; 21 | while (parent) { 22 | if (parent === document.body) { 23 | return document; 24 | } 25 | const { overflowY } = window.getComputedStyle(parent); 26 | const hasScrollableContent = parent.scrollHeight > parent.clientHeight; 27 | if ( 28 | hasScrollableContent && 29 | (overflowY === "auto" || 30 | overflowY === "scroll" || 31 | overflowY === "overlay") 32 | ) { 33 | return parent; 34 | } 35 | parent = parent.parentElement; 36 | } 37 | return document; 38 | }; 39 | 40 | function isDarwin(): boolean { 41 | return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); 42 | } 43 | 44 | function debounce void>(fn: T, ms = 0) { 45 | let timeoutId: number | any; 46 | return function (...args: Parameters) { 47 | clearTimeout(timeoutId); 48 | timeoutId = setTimeout(() => fn.apply(args), ms); 49 | }; 50 | } 51 | 52 | const MIN_ZOOM = 0.35; 53 | const MAX_ZOOM = 2; 54 | export const INITIAL_ZOOM = 1.0; 55 | 56 | export class TouchZoom { 57 | #node: HTMLElement; 58 | #scrollingAnchor: HTMLElement | Document; 59 | #gesture: Gesture; 60 | #resizeObserver: ResizeObserver; 61 | 62 | #bounds = { 63 | minX: 0, 64 | maxX: 0, 65 | minY: 0, 66 | maxY: 0, 67 | width: 0, 68 | height: 0, 69 | }; 70 | #originPoint: number[] | undefined = undefined; 71 | #delta: number[] = [0, 0]; 72 | #lastMovement = 1; 73 | #wheelLastTimeStamp = 0; 74 | 75 | #callbacks = new Set<(manual: boolean) => void>(); 76 | 77 | isPinching = false; 78 | center: number[] = [0, 0]; 79 | zoom = INITIAL_ZOOM; 80 | 81 | #preventGesture = (event: TouchEvent) => event.preventDefault(); 82 | 83 | constructor(node: HTMLElement) { 84 | this.#node = node; 85 | this.#scrollingAnchor = getNearestScrollableContainer(node); 86 | // @ts-ignore 87 | document.addEventListener("gesturestart", this.#preventGesture); 88 | // @ts-ignore 89 | document.addEventListener("gesturechange", this.#preventGesture); 90 | 91 | this.#updateBounds(); 92 | window.addEventListener("resize", this.#updateBoundsD); 93 | this.#scrollingAnchor.addEventListener("scroll", this.#updateBoundsD); 94 | 95 | this.#resizeObserver = new ResizeObserver((entries) => { 96 | if (this.isPinching) return; 97 | if (entries[0].contentRect) this.#updateBounds(); 98 | }); 99 | this.#resizeObserver.observe(node); 100 | 101 | this.#gesture = new Gesture( 102 | node, 103 | { 104 | onWheel: this.#handleWheel, 105 | onPinchStart: this.#handlePinchStart, 106 | onPinch: this.#handlePinch, 107 | onPinchEnd: this.#handlePinchEnd, 108 | onDrag: this.#handleDrag, 109 | }, 110 | { 111 | target: node, 112 | eventOptions: { passive: false }, 113 | pinch: { 114 | from: [this.zoom, 0], 115 | scaleBounds: () => { 116 | return { from: this.zoom, max: MAX_ZOOM, min: MIN_ZOOM }; 117 | }, 118 | }, 119 | drag: { 120 | filterTaps: true, 121 | pointer: { keys: false }, 122 | }, 123 | }, 124 | ); 125 | } 126 | 127 | #getPoint(e: PointerEvent | Touch | WheelEvent): number[] { 128 | return [ 129 | +e.clientX.toFixed(2) - this.#bounds.minX, 130 | +e.clientY.toFixed(2) - this.#bounds.minY, 131 | ]; 132 | } 133 | 134 | #updateBounds = () => { 135 | const rect = this.#node.getBoundingClientRect(); 136 | this.#bounds = { 137 | minX: rect.left, 138 | maxX: rect.left + rect.width, 139 | minY: rect.top, 140 | maxY: rect.top + rect.height, 141 | width: rect.width, 142 | height: rect.height, 143 | }; 144 | }; 145 | 146 | #updateBoundsD = debounce(this.#updateBounds, 100); 147 | 148 | onMove(callback: (manual: boolean) => void): () => void { 149 | this.#callbacks.add(callback); 150 | return () => this.#callbacks.delete(callback); 151 | } 152 | 153 | async moveTo(pos: number[], zoom: number) { 154 | // Cubic bezier easing 155 | const smoothstep = (z: number) => { 156 | const x = Math.max(0, Math.min(1, z)); 157 | return x * x * (3 - 2 * x); 158 | }; 159 | 160 | const beginTime = Date.now(); 161 | const totalTime = 350; // milliseconds 162 | 163 | const start = this.center; 164 | const startZ = 1 / this.zoom; 165 | const finishZ = 1 / zoom; 166 | while (true) { 167 | const t = Date.now() - beginTime; 168 | if (t > totalTime) break; 169 | const k = smoothstep(t / totalTime); 170 | 171 | this.center = Vec.lrp(start, pos, k); 172 | this.zoom = 1 / (startZ * (1 - k) + finishZ * k); 173 | this.#moved(false); 174 | await new Promise((resolve) => requestAnimationFrame(resolve)); 175 | } 176 | this.center = pos; 177 | this.zoom = zoom; 178 | this.#moved(false); 179 | } 180 | 181 | #moved(manual = true) { 182 | for (const callback of this.#callbacks) { 183 | callback(manual); 184 | } 185 | } 186 | 187 | #handleWheel: Handler<"wheel", WheelEvent> = ({ event: e }) => { 188 | e.preventDefault(); 189 | if (this.isPinching || e.timeStamp <= this.#wheelLastTimeStamp) return; 190 | 191 | this.#wheelLastTimeStamp = e.timeStamp; 192 | 193 | const [x, y, z] = normalizeWheel(e); 194 | 195 | // alt+scroll or ctrl+scroll = zoom (when not clicking) 196 | if ((e.altKey || e.ctrlKey || e.metaKey) && e.buttons === 0) { 197 | const point = 198 | e.clientX && e.clientY 199 | ? this.#getPoint(e) 200 | : [this.#bounds.width / 2, this.#bounds.height / 2]; 201 | const delta = z * 0.618; 202 | 203 | let newZoom = (1 - delta / 320) * this.zoom; 204 | newZoom = Vec.clamp(newZoom, MIN_ZOOM, MAX_ZOOM); 205 | 206 | const offset = Vec.sub(point, [0, 0]); 207 | const movement = Vec.mul(offset, 1 / this.zoom - 1 / newZoom); 208 | this.center = Vec.add(this.center, movement); 209 | this.zoom = newZoom; 210 | 211 | this.#moved(); 212 | return; 213 | } 214 | 215 | // otherwise pan 216 | const delta = Vec.mul( 217 | e.shiftKey && !isDarwin() 218 | ? // shift+scroll = pan horizontally 219 | [y, 0] 220 | : // scroll = pan vertically (or in any direction on a trackpad) 221 | [x, y], 222 | 0.5, 223 | ); 224 | 225 | if (Vec.isEqual(delta, [0, 0])) return; 226 | 227 | this.center = Vec.add(this.center, Vec.div(delta, this.zoom)); 228 | this.#moved(); 229 | }; 230 | 231 | #handlePinchStart: Handler< 232 | "pinch", 233 | WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent 234 | > = ({ origin, event }) => { 235 | if (event instanceof WheelEvent) return; 236 | 237 | this.isPinching = true; 238 | this.#originPoint = origin; 239 | this.#delta = [0, 0]; 240 | this.#lastMovement = 1; 241 | this.#moved(); 242 | }; 243 | 244 | #handlePinch: Handler< 245 | "pinch", 246 | WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent 247 | > = ({ origin, movement, event }) => { 248 | if (event instanceof WheelEvent) return; 249 | 250 | if (!this.#originPoint) return; 251 | const delta = Vec.sub(this.#originPoint, origin); 252 | const trueDelta = Vec.sub(delta, this.#delta); 253 | this.#delta = delta; 254 | 255 | const zoomLevel = movement[0] / this.#lastMovement; 256 | this.#lastMovement = movement[0]; 257 | 258 | this.center = Vec.add(this.center, Vec.div(trueDelta, this.zoom * 2)); 259 | this.zoom = Vec.clamp(this.zoom * zoomLevel, MIN_ZOOM, MAX_ZOOM); 260 | this.#moved(); 261 | }; 262 | 263 | #handlePinchEnd: Handler< 264 | "pinch", 265 | WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent 266 | > = () => { 267 | this.isPinching = false; 268 | this.#originPoint = undefined; 269 | this.#delta = [0, 0]; 270 | this.#lastMovement = 1; 271 | this.#moved(); 272 | }; 273 | 274 | #handleDrag: Handler< 275 | "drag", 276 | MouseEvent | PointerEvent | TouchEvent | KeyboardEvent 277 | > = ({ delta, elapsedTime }) => { 278 | if (delta[0] === 0 && delta[1] === 0 && elapsedTime < 200) return; 279 | this.center = Vec.sub(this.center, Vec.div(delta, this.zoom)); 280 | this.#moved(); 281 | }; 282 | 283 | destroy() { 284 | if (this.#node) { 285 | // @ts-ignore 286 | document.addEventListener("gesturestart", this.#preventGesture); 287 | // @ts-ignore 288 | document.addEventListener("gesturechange", this.#preventGesture); 289 | 290 | window.removeEventListener("resize", this.#updateBoundsD); 291 | this.#scrollingAnchor.removeEventListener("scroll", this.#updateBoundsD); 292 | 293 | this.#resizeObserver.disconnect(); 294 | 295 | this.#gesture.destroy(); 296 | this.#node = null as any; 297 | } 298 | } 299 | } 300 | 301 | // Reasonable defaults 302 | const MAX_ZOOM_STEP = 10; 303 | 304 | // Adapted from https://stackoverflow.com/a/13650579 305 | function normalizeWheel(event: WheelEvent) { 306 | const { deltaY, deltaX } = event; 307 | 308 | let deltaZ = 0; 309 | 310 | if (event.ctrlKey || event.metaKey) { 311 | const signY = Math.sign(event.deltaY); 312 | const absDeltaY = Math.abs(event.deltaY); 313 | 314 | let dy = deltaY; 315 | 316 | if (absDeltaY > MAX_ZOOM_STEP) { 317 | dy = MAX_ZOOM_STEP * signY; 318 | } 319 | 320 | deltaZ = dy; 321 | } 322 | 323 | return [deltaX, deltaY, deltaZ]; 324 | } 325 | -------------------------------------------------------------------------------- /src/lib/arrange.ts: -------------------------------------------------------------------------------- 1 | const ISECT_W = 752; 2 | const ISECT_H = 515; 3 | const ISECT_PAD = 16; 4 | 5 | type ExistingTerminal = { 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | }; 11 | 12 | /** Choose a position for a new terminal that does not intersect existing ones. */ 13 | export function arrangeNewTerminal(existing: ExistingTerminal[]) { 14 | if (existing.length === 0) { 15 | return { x: 0, y: 0 }; 16 | } 17 | 18 | const startX = 100 * (Math.random() - 0.5); 19 | const startY = 60 * (Math.random() - 0.5); 20 | 21 | for (let i = 0; ; i++) { 22 | const t = 1.94161103872 * i; 23 | const x = Math.round(startX + 8 * i * Math.cos(t)); 24 | const y = Math.round(startY + 8 * i * Math.sin(t)); 25 | let ok = true; 26 | for (const box of existing) { 27 | if ( 28 | isect(x, x + ISECT_W, box.x, box.x + box.width) && 29 | isect(y, y + ISECT_H, box.y, box.y + box.height) 30 | ) { 31 | ok = false; 32 | break; 33 | } 34 | } 35 | if (ok) { 36 | return { x, y }; 37 | } 38 | } 39 | } 40 | 41 | function isect(s1: number, e1: number, s2: number, e2: number): boolean { 42 | return s1 - ISECT_PAD < e2 && e1 + ISECT_PAD > s2; 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/assets/logotype-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/encrypt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Encryption of byte streams based on a random key. 3 | * 4 | * This is used for end-to-end encryption between the terminal source and its 5 | * client. Keep this file consistent with the Rust implementation. 6 | */ 7 | 8 | const SALT: string = 9 | "This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!"; 10 | 11 | export class Encrypt { 12 | private constructor(private aesKey: CryptoKey) {} 13 | 14 | static async new(key: string): Promise { 15 | const argon2 = await import( 16 | "argon2-browser/dist/argon2-bundled.min.js" as any 17 | ); 18 | const result = await argon2.hash({ 19 | pass: key, 20 | salt: SALT, 21 | type: argon2.ArgonType.Argon2id, 22 | mem: 19 * 1024, // Memory cost in KiB 23 | time: 2, // Number of iterations 24 | parallelism: 1, 25 | hashLen: 16, // Hash length in bytes 26 | }); 27 | const aesKey = await crypto.subtle.importKey( 28 | "raw", 29 | Uint8Array.from( 30 | result.hashHex 31 | .match(/.{1,2}/g) 32 | .map((byte: string) => parseInt(byte, 16)), 33 | ), 34 | { name: "AES-CTR" }, 35 | false, 36 | ["encrypt"], 37 | ); 38 | return new Encrypt(aesKey); 39 | } 40 | 41 | async zeros(): Promise { 42 | const zeros = new Uint8Array(16); 43 | const cipher = await crypto.subtle.encrypt( 44 | { name: "AES-CTR", counter: zeros, length: 64 }, 45 | this.aesKey, 46 | zeros, 47 | ); 48 | return new Uint8Array(cipher); 49 | } 50 | 51 | async segment( 52 | streamNum: bigint, 53 | offset: bigint, 54 | data: Uint8Array, 55 | ): Promise { 56 | if (streamNum === 0n) throw new Error("stream number must be nonzero"); // security check) 57 | 58 | const blockNum = offset >> 4n; 59 | const iv = new Uint8Array(16); 60 | new DataView(iv.buffer).setBigUint64(0, streamNum); 61 | new DataView(iv.buffer).setBigUint64(8, blockNum); 62 | 63 | const padBytes = Number(offset % 16n); 64 | const paddedData = new Uint8Array(padBytes + data.length); 65 | paddedData.set(data, padBytes); 66 | 67 | const encryptedData = await crypto.subtle.encrypt( 68 | { 69 | name: "AES-CTR", 70 | counter: iv, 71 | length: 64, 72 | }, 73 | this.aesKey, 74 | paddedData, 75 | ); 76 | return new Uint8Array(encryptedData, padBytes, data.length); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/lock.ts: -------------------------------------------------------------------------------- 1 | // Simple async lock for use in streaming encryption. 2 | // See . 3 | export function createLock() { 4 | const queue: (() => Promise)[] = []; 5 | let active = false; 6 | return (fn: () => Promise) => { 7 | let deferredResolve: any; 8 | let deferredReject: any; 9 | const deferred = new Promise((resolve, reject) => { 10 | deferredResolve = resolve; 11 | deferredReject = reject; 12 | }); 13 | const exec = async () => { 14 | await fn().then(deferredResolve, deferredReject); 15 | if (queue.length > 0) { 16 | queue.shift()!(); 17 | } else { 18 | active = false; 19 | } 20 | }; 21 | if (active) { 22 | queue.push(exec); 23 | } else { 24 | active = true; 25 | exec(); 26 | } 27 | return deferred; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/protocol.ts: -------------------------------------------------------------------------------- 1 | type Sid = number; // u32 2 | type Uid = number; // u32 3 | 4 | /** Position and size of a window, see the Rust version. */ 5 | export type WsWinsize = { 6 | x: number; 7 | y: number; 8 | rows: number; 9 | cols: number; 10 | }; 11 | 12 | /** Information about a user, see the Rust version */ 13 | export type WsUser = { 14 | name: string; 15 | cursor: [number, number] | null; 16 | focus: number | null; 17 | canWrite: boolean; 18 | }; 19 | 20 | /** Server message type, see the Rust version. */ 21 | export type WsServer = { 22 | hello?: [Uid, string]; 23 | invalidAuth?: []; 24 | users?: [Uid, WsUser][]; 25 | userDiff?: [Uid, WsUser | null]; 26 | shells?: [Sid, WsWinsize][]; 27 | chunks?: [Sid, number, Uint8Array[]]; 28 | hear?: [Uid, string, string]; 29 | shellLatency?: number | bigint; 30 | pong?: number | bigint; 31 | error?: string; 32 | }; 33 | 34 | /** Client message type, see the Rust version. */ 35 | export type WsClient = { 36 | authenticate?: [Uint8Array, Uint8Array | null]; 37 | setName?: string; 38 | setCursor?: [number, number] | null; 39 | setFocus?: number | null; 40 | create?: [number, number]; 41 | close?: Sid; 42 | move?: [Sid, WsWinsize | null]; 43 | data?: [Sid, Uint8Array, bigint]; 44 | subscribe?: [Sid, number]; 45 | chat?: string; 46 | ping?: bigint; 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/settings.ts: -------------------------------------------------------------------------------- 1 | import { persisted } from "svelte-persisted-store"; 2 | import themes, { type ThemeName, defaultTheme } from "./ui/themes"; 3 | import { derived, type Readable } from "svelte/store"; 4 | 5 | export type Settings = { 6 | name: string; 7 | theme: ThemeName; 8 | scrollback: number; 9 | }; 10 | 11 | const storedSettings = persisted>("sshx-settings-store", {}); 12 | 13 | /** A persisted store for settings of the current user. */ 14 | export const settings: Readable = derived( 15 | storedSettings, 16 | ($storedSettings) => { 17 | // Do some validation on all of the stored settings. 18 | const name = $storedSettings.name ?? ""; 19 | 20 | let theme = $storedSettings.theme; 21 | if (!theme || !Object.hasOwn(themes, theme)) { 22 | theme = defaultTheme; 23 | } 24 | 25 | let scrollback = $storedSettings.scrollback; 26 | if (typeof scrollback !== "number" || scrollback < 0) { 27 | scrollback = 5000; 28 | } 29 | 30 | return { 31 | name, 32 | theme, 33 | scrollback, 34 | }; 35 | }, 36 | ); 37 | 38 | export function updateSettings(values: Partial) { 39 | storedSettings.update((settings) => ({ ...settings, ...values })); 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/srocket.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Internal library for sshx, providing real-time communication. 3 | * 4 | * The contents of this file are technically general, not sshx-specific, but it 5 | * is not open-sourced as its own library because it's not ready for that. 6 | */ 7 | 8 | import { encode, decode } from "cbor-x"; 9 | 10 | /** How long to wait between reconnections (in milliseconds). */ 11 | const RECONNECT_DELAY = 500; 12 | 13 | /** Number of messages to queue while disconnected. */ 14 | const BUFFER_SIZE = 64; 15 | 16 | export type SrocketOptions = { 17 | /** Handle a message received from the server. */ 18 | onMessage(message: T): void; 19 | 20 | /** Called when the socket connects to the server. */ 21 | onConnect?(): void; 22 | 23 | /** Called when a connected socket is closed. */ 24 | onDisconnect?(): void; 25 | 26 | /** Called when an incoming or existing connection is closed. */ 27 | onClose?(event: CloseEvent): void; 28 | }; 29 | 30 | /** A reconnecting WebSocket client for real-time communication. */ 31 | export class Srocket { 32 | #url: string; 33 | #options: SrocketOptions; 34 | 35 | #ws: WebSocket | null; 36 | #connected: boolean; 37 | #buffer: Uint8Array[]; 38 | #disposed: boolean; 39 | 40 | constructor(url: string, options: SrocketOptions) { 41 | this.#url = url; 42 | if (this.#url.startsWith("/")) { 43 | // Get WebSocket URL relative to the current origin. 44 | this.#url = 45 | (window.location.protocol === "https:" ? "wss://" : "ws://") + 46 | window.location.host + 47 | this.#url; 48 | } 49 | this.#options = options; 50 | 51 | this.#ws = null; 52 | this.#connected = false; 53 | this.#buffer = []; 54 | this.#disposed = false; 55 | this.#reconnect(); 56 | } 57 | 58 | get connected() { 59 | return this.#connected; 60 | } 61 | 62 | /** Queue a message to send to the server, with "at-most-once" semantics. */ 63 | send(message: U) { 64 | // Types in cbor-x are incorrect here, so cast to fix the error. 65 | // See: https://github.com/kriszyp/cbor-x/issues/120 66 | const data = (encode(message) as unknown); 67 | 68 | if (this.#connected && this.#ws) { 69 | this.#ws.send(data); 70 | } else { 71 | if (this.#buffer.length < BUFFER_SIZE) { 72 | this.#buffer.push(data); 73 | } 74 | } 75 | } 76 | 77 | /** Dispose of this WebSocket permanently. */ 78 | dispose() { 79 | this.#stateChange(false); 80 | this.#disposed = true; 81 | this.#ws?.close(); 82 | } 83 | 84 | #reconnect() { 85 | if (this.#disposed) return; 86 | if (this.#ws !== null) { 87 | throw new Error("invariant violation: reconnecting while connected"); 88 | } 89 | this.#ws = new WebSocket(this.#url); 90 | this.#ws.binaryType = "arraybuffer"; 91 | this.#ws.onopen = () => { 92 | this.#stateChange(true); 93 | }; 94 | this.#ws.onclose = (event) => { 95 | this.#options.onClose?.(event); 96 | this.#ws = null; 97 | this.#stateChange(false); 98 | setTimeout(() => this.#reconnect(), RECONNECT_DELAY); 99 | }; 100 | this.#ws.onmessage = (event) => { 101 | if (event.data instanceof ArrayBuffer) { 102 | const message: T = decode(new Uint8Array(event.data)); 103 | this.#options.onMessage(message); 104 | } else { 105 | console.warn("unexpected non-buffer message, ignoring"); 106 | } 107 | }; 108 | } 109 | 110 | #stateChange(connected: boolean) { 111 | if (!this.#disposed && connected !== this.#connected) { 112 | this.#connected = connected; 113 | if (connected) { 114 | this.#options.onConnect?.(); 115 | 116 | if (!this.#ws) { 117 | throw new Error("invariant violation: connected but ws is null"); 118 | } 119 | // Send any queued messages. 120 | for (const message of this.#buffer) { 121 | this.#ws.send(message); 122 | } 123 | this.#buffer = []; 124 | } else { 125 | this.#options.onDisconnect?.(); 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/lib/toast.ts: -------------------------------------------------------------------------------- 1 | /** @file Provides a simple, native toast library. */ 2 | 3 | import { writable } from "svelte/store"; 4 | 5 | export const toastStore = writable<(Toast & { expires: number })[]>([]); 6 | 7 | export type Toast = { 8 | kind: "info" | "success" | "error"; 9 | message: string; 10 | action?: string; 11 | onAction?: () => void; 12 | }; 13 | 14 | export function makeToast(toast: Toast, duration = 3000) { 15 | const obj = Object.assign({ expires: Date.now() + duration }, toast); 16 | toastStore.update(($toasts) => [...$toasts, obj]); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/ui/Avatars.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 | {#each users as [id, user] (id)} 23 |
28 | {nameToInitials(user.name)} 29 |
30 | {/each} 31 |
32 | 33 | 39 | -------------------------------------------------------------------------------- /src/lib/ui/Chat.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 53 | 54 |
59 |
60 | 61 | dispatch("close")} /> 62 | 63 |
Chat Messages
64 |
65 | 66 |
67 |
68 | {#each groupedMessages as chatGroup} 69 |
70 | 73 | {#each chatGroup as chat (chat)} 74 |
78 | {chat.msg} 79 |
80 | {/each} 81 |
82 | {/each} 83 |
84 |
85 | 86 |
87 | 92 | {#if text} 93 | 101 | {/if} 102 |
103 |
104 | 105 | 128 | -------------------------------------------------------------------------------- /src/lib/ui/ChooseName.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 |
21 | 29 | 33 |
34 |
35 | -------------------------------------------------------------------------------- /src/lib/ui/CircleButton.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /src/lib/ui/CircleButtons.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /src/lib/ui/CopyableCode.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | {value} 19 | 30 |
31 | -------------------------------------------------------------------------------- /src/lib/ui/DownloadLink.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/ui/LiveCursor.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | 40 |
(hovering = true)} 43 | on:mouseleave={() => (hovering = false)} 44 | > 45 | 46 | 51 | 52 | {#if showName || hovering || time - lastMove < 1500} 53 |

57 | {user.name} 58 |

59 | {/if} 60 |
61 | -------------------------------------------------------------------------------- /src/lib/ui/NameList.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    14 | {#each sortedUsers as [id, user] (id)} 15 |
  • 19 |
    23 |
    26 | {user.name} 27 |
    28 |
  • 29 | {/each} 30 |
31 | -------------------------------------------------------------------------------- /src/lib/ui/NetworkInfo.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 |
37 |
38 | 39 | 40 | 41 |
42 | 43 |

Network

44 |

45 | {#if status === "connected"} 46 | {#if serverLatency === null || shellLatency === null} 47 | Connected, estimating latency… 48 | {:else} 49 | Total latency: {displayLatency(serverLatency + shellLatency)} 50 | {/if} 51 | {:else} 52 | You are currently disconnected. 53 | {/if} 54 |

55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | 64 |
65 |

You

66 | 67 | {#if status === "connected"} 68 |

69 | {#if serverLatency !== null} 70 | ~{displayLatency(serverLatency)} 71 | {/if} 72 |

73 | {/if} 74 | 75 |

Server

76 | 77 | {#if status === "connected"} 78 |

79 | {#if shellLatency !== null} 80 | ~{displayLatency(shellLatency)} 81 | {/if} 82 |

83 | {/if} 84 | 85 |

Shell

86 |
87 |
88 | 89 | 102 | -------------------------------------------------------------------------------- /src/lib/ui/OverlayMenu.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | 26 | 36 |
40 | {#if showCloseButton} 41 | 48 | {/if} 49 | 50 |
51 | 52 | {title} 53 | 54 | 55 | {description} 56 | 57 |
58 | 59 | 60 |
61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /src/lib/ui/Settings.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 31 |
32 |
33 |
34 |

Name

35 |

Choose how you appear to other users.

36 |
37 |
38 | { 44 | if (inputName.length >= 2) { 45 | updateSettings({ name: inputName }); 46 | } 47 | }} 48 | /> 49 |
50 |
51 |
52 |
53 |

Color palette

54 |

Color theme for text in terminals.

55 |
56 |
57 | 60 | 69 |
70 |
71 |
72 |
73 |

Scrollback

74 |

75 | Lines of previous text displayed in the terminal window. 76 |

77 |
78 |
79 | { 84 | if (inputScrollback >= 0) { 85 | updateSettings({ scrollback: inputScrollback }); 86 | } 87 | }} 88 | step="100" 89 | /> 90 |
91 |
92 | 99 |
100 | 101 | 102 |

103 | sshx-server v{__APP_VERSION__} 106 |

107 |
108 | 109 | 132 | -------------------------------------------------------------------------------- /src/lib/ui/TeaserVideo.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | sshx logo 23 |

sshx

24 | 25 |
26 |
27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 |

36 | sshx.io/s/gzN0WHsm6r#tiOAVOLsNXEZxJ 37 |

38 |
39 |
40 | 41 | 42 |
43 |
44 | 57 |
58 | -------------------------------------------------------------------------------- /src/lib/ui/Toast.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | {#if kind === "info"} 24 | 25 | {:else if kind === "success"} 26 | 27 | {:else if kind === "error"} 28 | 29 | {:else} 30 | 31 | {/if} 32 | 33 |

34 | {message} 35 |

36 | 37 | {#if action} 38 |
39 | 45 |
46 | {/if} 47 |
48 | 49 | 56 | -------------------------------------------------------------------------------- /src/lib/ui/ToastContainer.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 |
22 |
23 | {#each $toastStore.slice().reverse() as toast (toast)} 24 |
27 | ($toastStore = $toastStore.filter((t) => t !== toast))} 28 | on:keypress={() => null} 29 | animate:flip={{ duration: 500 }} 30 | transition:fly={{ x: 360, duration: 500 }} 31 | > 32 | null)} 37 | /> 38 |
39 | {/each} 40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /src/lib/ui/Toolbar.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 |
26 | sshx logo 29 |

sshx

30 | 31 |
32 | 33 |
34 | 46 | 52 | 55 |
56 | 57 |
58 | 59 |
60 | 63 |
64 |
65 |
66 | 67 | 81 | -------------------------------------------------------------------------------- /src/lib/ui/XTerm.svelte: -------------------------------------------------------------------------------- 1 | 2 | 34 | 35 | 216 | 217 |
dispatch("bringToFront")} 222 | on:pointerdown={(event) => event.stopPropagation()} 223 | > 224 |
dispatch("startMove", event)} 227 | > 228 |
229 | 230 | 234 | event.button === 0 && dispatch("close")} 237 | /> 238 | event.button === 0 && dispatch("shrink")} 241 | /> 242 | event.button === 0 && dispatch("expand")} 245 | /> 246 | 247 |
248 |
251 | {currentTitle} 252 |
253 |
254 |
255 |
{ 260 | if (focused) { 261 | // Don't pan the page when scrolling while the terminal is selected. 262 | // Conversely, we manually disable terminal scrolling unless it is currently selected. 263 | event.stopPropagation(); 264 | } 265 | }} 266 | /> 267 |
268 | 269 | 283 | -------------------------------------------------------------------------------- /src/lib/ui/themes.ts: -------------------------------------------------------------------------------- 1 | import type { ITheme } from "sshx-xterm"; 2 | 3 | /** VSCode default dark theme, from https://glitchbone.github.io/vscode-base16-term/. */ 4 | const defaultDark: ITheme = { 5 | foreground: "#d8d8d8", 6 | background: "#181818", 7 | 8 | cursor: "#d8d8d8", 9 | 10 | black: "#181818", 11 | red: "#ab4642", 12 | green: "#a1b56c", 13 | yellow: "#f7ca88", 14 | blue: "#7cafc2", 15 | magenta: "#ba8baf", 16 | cyan: "#86c1b9", 17 | white: "#d8d8d8", 18 | 19 | brightBlack: "#585858", 20 | brightRed: "#ab4642", 21 | brightGreen: "#a1b56c", 22 | brightYellow: "#f7ca88", 23 | brightBlue: "#7cafc2", 24 | brightMagenta: "#ba8baf", 25 | brightCyan: "#86c1b9", 26 | brightWhite: "#f8f8f8", 27 | }; 28 | 29 | /** Hybrid theme from https://terminal.sexy/, using Alacritty export format. */ 30 | const hybrid: ITheme = { 31 | foreground: "#c5c8c6", 32 | background: "#1d1f21", 33 | 34 | black: "#282a2e", 35 | red: "#a54242", 36 | green: "#8c9440", 37 | yellow: "#de935f", 38 | blue: "#5f819d", 39 | magenta: "#85678f", 40 | cyan: "#5e8d87", 41 | white: "#707880", 42 | 43 | brightBlack: "#373b41", 44 | brightRed: "#cc6666", 45 | brightGreen: "#b5bd68", 46 | brightYellow: "#f0c674", 47 | brightBlue: "#81a2be", 48 | brightMagenta: "#b294bb", 49 | brightCyan: "#8abeb7", 50 | brightWhite: "#c5c8c6", 51 | }; 52 | 53 | /** Below themes are converted from https://github.com/alacritty/alacritty-theme/. */ 54 | const rosePine: ITheme = { 55 | foreground: "#e0def4", 56 | background: "#191724", 57 | 58 | cursor: "#524f67", 59 | 60 | black: "#26233a", 61 | red: "#eb6f92", 62 | green: "#31748f", 63 | yellow: "#f6c177", 64 | blue: "#9ccfd8", 65 | magenta: "#c4a7e7", 66 | cyan: "#ebbcba", 67 | white: "#e0def4", 68 | 69 | brightBlack: "#6e6a86", 70 | brightRed: "#eb6f92", 71 | brightGreen: "#31748f", 72 | brightYellow: "#f6c177", 73 | brightBlue: "#9ccfd8", 74 | brightMagenta: "#c4a7e7", 75 | brightCyan: "#ebbcba", 76 | brightWhite: "#e0def4", 77 | }; 78 | 79 | const ubuntu: ITheme = { 80 | foreground: "#eeeeec", 81 | background: "#300a24", 82 | black: "#2e3436", 83 | red: "#cc0000", 84 | green: "#4e9a06", 85 | yellow: "#c4a000", 86 | blue: "#3465a4", 87 | magenta: "#75507b", 88 | cyan: "#06989a", 89 | white: "#d3d7cf", 90 | brightBlack: "#555753", 91 | brightRed: "#ef2929", 92 | brightGreen: "#8ae234", 93 | brightYellow: "#fce94f", 94 | brightBlue: "#729fcf", 95 | brightMagenta: "#ad7fa8", 96 | brightCyan: "#34e2e2", 97 | brightWhite: "#eeeeec", 98 | }; 99 | 100 | const dracula: ITheme = { 101 | foreground: "#f8f8f2", 102 | background: "#282a36", 103 | black: "#000000", 104 | red: "#ff5555", 105 | green: "#50fa7b", 106 | yellow: "#f1fa8c", 107 | blue: "#bd93f9", 108 | magenta: "#ff79c6", 109 | cyan: "#8be9fd", 110 | white: "#bbbbbb", 111 | brightBlack: "#555555", 112 | brightRed: "#ff5555", 113 | brightGreen: "#50fa7b", 114 | brightYellow: "#f1fa8c", 115 | brightBlue: "#caa9fa", 116 | brightMagenta: "#ff79c6", 117 | brightCyan: "#8be9fd", 118 | brightWhite: "#ffffff", 119 | }; 120 | 121 | const githubDark: ITheme = { 122 | foreground: "#d1d5da", 123 | background: "#24292e", 124 | black: "#586069", 125 | red: "#ea4a5a", 126 | green: "#34d058", 127 | yellow: "#ffea7f", 128 | blue: "#2188ff", 129 | magenta: "#b392f0", 130 | cyan: "#39c5cf", 131 | white: "#d1d5da", 132 | brightBlack: "#959da5", 133 | brightRed: "#f97583", 134 | brightGreen: "#85e89d", 135 | brightYellow: "#ffea7f", 136 | brightBlue: "#79b8ff", 137 | brightMagenta: "#b392f0", 138 | brightCyan: "#56d4dd", 139 | brightWhite: "#fafbfc", 140 | }; 141 | 142 | const gruvboxDark: ITheme = { 143 | foreground: "#ebdbb2", 144 | background: "#282828", 145 | black: "#282828", 146 | red: "#cc241d", 147 | green: "#98971a", 148 | yellow: "#d79921", 149 | blue: "#458588", 150 | magenta: "#b16286", 151 | cyan: "#689d6a", 152 | white: "#a89984", 153 | brightBlack: "#928374", 154 | brightRed: "#fb4934", 155 | brightGreen: "#b8bb26", 156 | brightYellow: "#fabd2f", 157 | brightBlue: "#83a598", 158 | brightMagenta: "#d3869b", 159 | brightCyan: "#8ec07c", 160 | brightWhite: "#ebdbb2", 161 | }; 162 | 163 | const solarizedDark: ITheme = { 164 | foreground: "#839496", 165 | background: "#002b36", 166 | black: "#073642", 167 | red: "#dc322f", 168 | green: "#859900", 169 | yellow: "#b58900", 170 | blue: "#268bd2", 171 | magenta: "#d33682", 172 | cyan: "#2aa198", 173 | white: "#eee8d5", 174 | brightBlack: "#002b36", 175 | brightRed: "#cb4b16", 176 | brightGreen: "#586e75", 177 | brightYellow: "#657b83", 178 | brightBlue: "#839496", 179 | brightMagenta: "#6c71c4", 180 | brightCyan: "#93a1a1", 181 | brightWhite: "#fdf6e3", 182 | }; 183 | 184 | const tokyoNight: ITheme = { 185 | foreground: "#a9b1d6", 186 | background: "#1a1b26", 187 | black: "#32344a", 188 | red: "#f7768e", 189 | green: "#9ece6a", 190 | yellow: "#e0af68", 191 | blue: "#7aa2f7", 192 | magenta: "#ad8ee6", 193 | cyan: "#449dab", 194 | white: "#787c99", 195 | brightBlack: "#444b6a", 196 | brightRed: "#ff7a93", 197 | brightGreen: "#b9f27c", 198 | brightYellow: "#ff9e64", 199 | brightBlue: "#7da6ff", 200 | brightMagenta: "#bb9af7", 201 | brightCyan: "#0db9d7", 202 | brightWhite: "#acb0d0", 203 | }; 204 | 205 | const themes = { 206 | "VS Code Dark": defaultDark, 207 | Hybrid: hybrid, 208 | "Rosé Pine": rosePine, 209 | Ubuntu: ubuntu, 210 | Dracula: dracula, 211 | "GitHub Dark": githubDark, 212 | "Gruvbox Dark": gruvboxDark, 213 | "Solarized Dark": solarizedDark, 214 | "Tokyo Night": tokyoNight, 215 | }; 216 | 217 | export type ThemeName = keyof typeof themes; 218 | 219 | export const defaultTheme: ThemeName = "VS Code Dark"; 220 | 221 | export default themes; 222 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | sshx logo 9 | 10 |
11 |

12 | {#if $page.status === 404} 13 | 404 Not Found. We couldn't find this page, sorry! 14 | {:else} 15 | Error {$page.status}. An unexpected error occurred. 16 | {/if} 17 |

18 | {#if $page.status !== 404} 19 |
20 | {JSON.stringify($page.error, null, 2)}
21 | 
22 | {/if} 23 |

24 | Perhaps try coming back later? If you have any feedback, please feel free 25 | to reach out at 26 | ekzhang1@gmail.com. 29 |

30 |
31 | 32 | Return home 37 |
38 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
50 |
51 | sshx logo 52 |
53 |

56 | A secure web-based, 57 | collaborative terminal 58 |

59 | 60 |
61 |
64 | 65 |
66 |
67 | two terminal windows running sshx and three live cursors 74 |
75 |
76 | 77 |
78 |

79 | sshx lets you share your terminal with anyone by 80 | link, on a 81 | multiplayer infinite canvas. 82 |

83 |

84 | It has real-time collaboration, with remote cursors and chat. It's 85 | also fast and end-to-end encrypted, with a lightweight 86 | server written in Rust. 87 |

88 |

89 | Install sshx with a single command. Use it for teaching, 90 | debugging, or cloud access. 91 |

92 |
93 | 94 |
95 | 101 |
102 | 103 |
104 |
105 |
106 | 107 |
108 |

Collaborative

109 |

Invite people by sharing a secure, unique browser link.

110 |
111 |
112 |
113 | 114 |
115 |

End-to-end encrypted

116 |

Send data securely; the server never sees what you're typing.

117 |
118 |
119 |
120 | 121 |
122 |

Cross-platform

123 |

Use the command-line tool on macOS, Linux, and Windows.

124 |
125 |
126 |
127 | 128 |
129 |

Infinite canvas

130 |

Move and resize multiple terminals at once, in any arrangement.

131 |
132 |
133 |
134 | 135 |
136 |

Live presence

137 |

See other people's names and cursors within the app.

138 |
139 |
140 |
141 | 142 |
143 |

Ultra-fast mesh networking

144 |

145 | Connect from anywhere to the nearest distributed peer in a global 146 | network. 147 |

148 |
149 |
150 | 151 | 154 | 155 |

159 | Installation 160 |

161 | 162 |
163 |

164 | 165 | macOS / Linux 166 |

167 |
168 |

Run the following in your terminal:

169 | 170 | 171 |

Or, download the binary for your platform.

172 |
173 | macOS ARM64 (Apple Silicon) 177 | macOS x86-64 (Intel) 181 |
182 |
183 | Linux ARM64 187 | Linux x86-64 191 | Linux ARMv6 195 | Linux ARMv7 199 |
200 |
201 | FreeBSD x86-64 205 |
206 |
207 |
208 | 209 |
210 |

211 | 212 | Windows 213 |

214 |
215 |

Download the executable for your platform.

216 | 217 |
218 | Windows x86-64 222 | Windows x86 226 | Windows ARM64 230 |
231 |
232 |
233 | 234 |
235 |

236 | 237 | Build from source 238 |

239 |
240 |

241 | Ensure you have up-to-date versions of Rust and protoc. Compile sshx and 242 | add it to the system path. 243 |

244 | 245 |
246 |
247 | 248 |
249 |

250 | 251 | GitHub Actions 252 |

253 |
254 |

255 | On GitHub Actions or other CI providers, run this command. It pauses 256 | your workflow and starts a collaborative session. 257 |

258 | 259 |
260 |
261 | 262 |
263 | 264 |
265 | {#each socials as social} 266 | 272 | {social.title} 273 | 274 | {/each} 275 |
276 | 277 |

278 | open source, © Eric Zhang 2023 279 |

280 |
281 | 282 | 343 | -------------------------------------------------------------------------------- /src/routes/+page.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/s/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | {title} 11 | 12 | 17 | 18 | 19 | { 22 | if (sessionName) { 23 | title = `${sessionName} | sshx`; 24 | } 25 | }} 26 | /> 27 | -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /static/get: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This is a short script to install the latest version of the sshx binary. 4 | # 5 | # It's meant to be as simple as possible, so if you're not happy hardcoding a 6 | # `curl | sh` pipe in your application, you can just download the binary 7 | # directly with the appropriate URL for your architecture. 8 | # 9 | # If you'd like to run it without installing to /usr/local/bin, use `sh -s run`. 10 | # To download to the current directory, use `sh -s download`. 11 | 12 | set +e 13 | 14 | case "$(uname -s)" in 15 | Linux*) suffix="-unknown-linux-musl";; 16 | Darwin*) suffix="-apple-darwin";; 17 | FreeBSD*) suffix="-unknown-freebsd";; 18 | MINGW*|MSYS*|CYGWIN*) 19 | echo "You are on Windows. Please visit sshx.io to download the executable."; 20 | exit 1;; 21 | *) echo "Unsupported OS $(uname -s)"; exit 1;; 22 | esac 23 | 24 | case "$(uname -m)" in 25 | aarch64 | aarch64_be | arm64 | armv8b | armv8l) arch="aarch64";; 26 | x86_64 | x64 | amd64) arch="x86_64";; 27 | armv6l) arch="arm"; suffix="${suffix}eabihf";; 28 | armv7l) arch="armv7"; suffix="${suffix}eabihf";; 29 | *) echo "Unsupported arch $(uname -m)"; exit 1;; 30 | esac 31 | 32 | url="https://s3.amazonaws.com/sshx/sshx-${arch}${suffix}.tar.gz" 33 | 34 | if [ -z "$NO_COLOR" ]; then 35 | ansi_reset="\033[0m" 36 | ansi_info="\033[35;1m" 37 | ansi_error="\033[31m" 38 | ansi_underline="\033[4m" 39 | fi 40 | 41 | cmd=${1:-install} 42 | temp=$(mktemp) 43 | 44 | case $cmd in 45 | "run") 46 | path=$(mktemp -d) 47 | will_run=1 48 | ;; 49 | "download") 50 | path=$(pwd) 51 | ;; 52 | "install") 53 | path=/usr/local/bin 54 | ;; 55 | *) 56 | printf "${ansi_error}Error: Invalid command. Please use 'run', 'download', or 'install'.\n" 57 | exit 1 58 | ;; 59 | esac 60 | 61 | printf "${ansi_reset}${ansi_info}↯ Downloading sshx from ${ansi_underline}%s${ansi_reset}\n" "$url" 62 | http_code=$(curl "$url" -o "$temp" -w "%{http_code}") 63 | if [ "$http_code" -lt 200 ] || [ "$http_code" -gt 299 ]; then 64 | printf "${ansi_error}Error: Request had status code ${http_code}.\n" 65 | cat "$temp" 1>&2 66 | printf "${ansi_reset}\n" 67 | exit 1 68 | fi 69 | 70 | printf "\n${ansi_reset}${ansi_info}↯ Adding sshx binary to ${ansi_underline}%s${ansi_reset}\n" "$path" 71 | if [ "$(id -u)" -ne 0 ] && [ "$path" = "/usr/local/bin" ]; then 72 | sudo tar xf "$temp" -C "$path" || exit 1 73 | else 74 | tar xf "$temp" -C "$path" || exit 1 75 | fi 76 | 77 | printf "\n${ansi_reset}${ansi_info}↯ Done! You can now run sshx.${ansi_reset}\n" 78 | 79 | if [ -n "$will_run" ]; then 80 | "$path/sshx" 81 | rm -f "$path/sshx" 82 | fi 83 | -------------------------------------------------------------------------------- /static/images/social-image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekzhang/sshx/5dd5ff9558e9cb9248baeba02966016755a6fd5f/static/images/social-image2.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-static"; 2 | import preprocess from "svelte-preprocess"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: [ 9 | preprocess({ 10 | postcss: true, 11 | }), 12 | ], 13 | 14 | kit: { 15 | adapter: adapter({ 16 | fallback: "spa.html", // SPA mode 17 | precompress: true, 18 | }), 19 | }, 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | 3 | /** @type {import("tailwindcss").Config} */ 4 | const config = { 5 | content: ["./src/**/*.{html,js,svelte,ts}"], 6 | 7 | darkMode: "class", 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ["Inter Variable", ...defaultTheme.fontFamily.sans], 12 | mono: ["Fira Code VF", ...defaultTheme.fontFamily.mono], 13 | }, 14 | }, 15 | }, 16 | 17 | plugins: [], 18 | }; 19 | 20 | module.exports = config; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | 3 | import { defineConfig } from "vite"; 4 | import { sveltekit } from "@sveltejs/kit/vite"; 5 | 6 | const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); 7 | 8 | export default defineConfig({ 9 | define: { 10 | __APP_VERSION__: JSON.stringify("0.4.1-" + commitHash), 11 | }, 12 | 13 | plugins: [sveltekit()], 14 | 15 | server: { 16 | proxy: { 17 | "/api": { 18 | target: "http://[::1]:8051", 19 | changeOrigin: true, 20 | ws: true, 21 | }, 22 | }, 23 | }, 24 | }); 25 | --------------------------------------------------------------------------------