Repository: ekzhang/sshx Branch: main Commit: dd42496be83d Files: 90 Total size: 288.6 KB Directory structure: gitextract_vdc0p1pc/ ├── .editorconfig ├── .eslintrc.cjs ├── .github/ │ └── workflows/ │ └── ci.yaml ├── .gitignore ├── .prettierrc ├── Cargo.toml ├── Cross.toml ├── Dockerfile ├── LICENSE ├── README.md ├── compose.yaml ├── crates/ │ ├── sshx/ │ │ ├── Cargo.toml │ │ ├── examples/ │ │ │ └── stdin_client.rs │ │ └── src/ │ │ ├── controller.rs │ │ ├── encrypt.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── runner.rs │ │ ├── terminal/ │ │ │ ├── unix.rs │ │ │ └── windows.rs │ │ └── terminal.rs │ ├── 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/ │ │ │ └── snapshot.rs │ │ ├── session.rs │ │ ├── state/ │ │ │ └── mesh.rs │ │ ├── state.rs │ │ ├── utils.rs │ │ ├── web/ │ │ │ ├── protocol.rs │ │ │ └── socket.rs │ │ └── web.rs │ └── tests/ │ ├── common/ │ │ └── mod.rs │ ├── simple.rs │ ├── snapshot.rs │ └── with_client.rs ├── fly.toml ├── mprocs.yaml ├── 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 │ │ ├── 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/ │ └── get ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*] end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.rs] tab_width = 4 [*.{js,jsx,ts,tsx,html,css,svelte,proto}] tab_width = 2 ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { root: true, parser: "@typescript-eslint/parser", extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", ], plugins: ["svelte3", "@typescript-eslint"], ignorePatterns: ["*.cjs"], overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], settings: { "svelte3/typescript": () => require("typescript"), }, rules: { "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/no-non-null-assertion": "off", "no-constant-condition": "off", "no-control-regex": "off", "no-empty": "off", "no-undef": "off", }, parserOptions: { sourceType: "module", ecmaVersion: 2020, }, env: { browser: true, es2017: true, node: true, }, }; ================================================ FILE: .github/workflows/ci.yaml ================================================ name: CI on: push: branches: - main pull_request: branches: - main jobs: rustfmt: name: Rust format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: rustup toolchain install nightly --profile minimal -c rustfmt - run: cargo +nightly fmt -- --check rust: name: Rust lint and test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: arduino/setup-protoc@v2 - run: rustup toolchain install stable - uses: Swatinem/rust-cache@v2 - run: cargo test - run: cargo clippy --all-targets -- -D warnings windows_test: name: Client test (Windows) runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: arduino/setup-protoc@v2 - run: rustup toolchain install stable - uses: Swatinem/rust-cache@v2 - run: cargo test -p sshx web: name: Web lint, check, and build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "18" - run: npm ci - run: npm run lint - run: npm run check - run: npm run build deploy: name: Deploy runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [rustfmt, rust, web] concurrency: group: deploy cancel-in-progress: true steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@v1 - run: flyctl deploy env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .gitignore ================================================ /.vscode /target /node_modules /.svelte-kit /build ================================================ FILE: .prettierrc ================================================ { "proseWrap": "always", "trailingComma": "all" } ================================================ FILE: Cargo.toml ================================================ [workspace] members = ["crates/*"] resolver = "2" [workspace.package] version = "0.4.1" authors = ["Eric Zhang "] license = "MIT" description = "A secure web-based, collaborative terminal." repository = "https://github.com/ekzhang/sshx" documentation = "https://sshx.io" keywords = ["ssh", "share", "terminal", "collaborative"] [workspace.dependencies] anyhow = "1.0.62" clap = { version = "4.5.17", features = ["derive", "env"] } prost = "0.13.4" rand = "0.8.5" serde = { version = "1.0.188", features = ["derive", "rc"] } sshx-core = { version = "0.4.1", path = "crates/sshx-core" } tokio = { version = "1.40.0", features = ["full"] } tokio-stream = { version = "0.1.14", features = ["sync"] } tonic = { version = "0.12.3", features = ["tls", "tls-webpki-roots"] } tonic-build = "0.12.3" tonic-reflection = "0.12.3" tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } [profile.release] strip = true ================================================ FILE: Cross.toml ================================================ [target.x86_64-unknown-freebsd] pre-build = [ "apt-get update", # Protobuf version is too outdated on the cargo-cross image, which is ubuntu:20.04. # "apt install -y protobuf-compiler", "apt install -y wget libarchive-tools", "mkdir /protoc", "wget -qO- https://github.com/protocolbuffers/protobuf/releases/download/v29.2/protoc-29.2-linux-x86_64.zip | bsdtar -xvf- -C /protoc", "mv -v /protoc/bin/protoc /usr/local/bin && chmod +x /usr/local/bin/protoc", "mkdir -p /usr/local/include/google/protobuf/", "mv -v /protoc/include/google/protobuf/* /usr/local/include/google/protobuf/", "rm -rf /protoc", ] ================================================ FILE: Dockerfile ================================================ FROM rust:alpine AS backend WORKDIR /home/rust/src RUN apk --no-cache add musl-dev openssl-dev protoc RUN rustup component add rustfmt COPY . . RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/home/rust/src/target \ cargo build --release --bin sshx-server && \ cp target/release/sshx-server /usr/local/bin FROM node:lts-alpine AS frontend RUN apk --no-cache add git WORKDIR /usr/src/app COPY . . RUN npm ci RUN npm run build FROM alpine:latest WORKDIR /root COPY --from=frontend /usr/src/app/build build COPY --from=backend /usr/local/bin/sshx-server . CMD ["./sshx-server", "--listen", "::"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Eric Zhang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # sshx A secure web-based, collaborative terminal. ![](https://i.imgur.com/Q3qKAHW.png) **Features:** - Run a single command to share your terminal with anyone. - Resize, move windows, and freely zoom and pan on an infinite canvas. - See other people's cursors moving in real time. - Connect to the nearest server in a globally distributed mesh. - End-to-end encryption with Argon2 and AES. - Automatic reconnection and real-time latency estimates. - Predictive echo for faster local editing (à la Mosh). Visit [sshx.io](https://sshx.io) to learn more. ## Installation Just run this command to get the `sshx` binary for your platform. ```shell curl -sSf https://sshx.io/get | sh ``` Supports Linux and MacOS on x86_64 and ARM64 architectures, as well as embedded ARMv6 and ARMv7-A systems. The Linux binaries are statically linked. For Windows, there are binaries for x86_64, x86, and ARM64, linked to MSVC for maximum compatibility. If you just want to try it out without installing, use: ```shell curl -sSf https://sshx.io/get | sh -s run ``` Inspect the script for additional options. You can also install it with [Homebrew](https://brew.sh/) on macOS. ```shell brew install sshx ``` ### CI/CD You can run sshx in continuous integration workflows to help debug tricky issues, like in GitHub Actions. ```yaml name: CI on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 # ... other steps ... - run: curl -sSf https://sshx.io/get | sh -s run # ^ # └ This will open a remote terminal session and print the URL. It # should take under a second. ``` We don't have a prepackaged action because it's just a single command. It works anywhere: GitLab CI, CircleCI, Buildkite, CI on your Raspberry Pi, etc. Be careful adding this to a public GitHub repository, as any user can view the logs of a CI job while it is running. ## Development Here's how to work on the project, if you want to contribute. ### Building from source To build the latest version of the client from source, clone this repository and run, with [Rust](https://rust-lang.com/) installed: ```shell cargo install --path crates/sshx ``` This will compile the `sshx` binary and place it in your `~/.cargo/bin` folder. ### Workflow First, start service containers for development. ```shell docker compose up -d ``` Install [Rust 1.70+](https://www.rust-lang.org/), [Node v18](https://nodejs.org/), [NPM v9](https://www.npmjs.com/), and [mprocs](https://github.com/pvolok/mprocs). Then, run ```shell npm install mprocs ``` This will compile and start the server, an instance of the client, and the web frontend in parallel on your machine. ## Deployment I host the application servers on [Fly.io](https://fly.io/) and with [Redis Cloud](https://redis.com/). Self-hosted deployments are not supported at the moment. If you want to deploy sshx, you'll need to properly implement HTTP/TCP reverse proxies, gRPC forwarding, TLS termination, private mesh networking, and graceful shutdown. Please do not run the development commands in a public setting, as this is insecure. ================================================ FILE: compose.yaml ================================================ # Services used by sshx for development. These listen on ports 126XX, to reduce the chance that they # conflict with other processes. # # You can start them with `docker compose up -d`. services: redis: image: bitnami/redis:7.2 environment: - ALLOW_EMPTY_PASSWORD=yes ports: - 127.0.0.1:12601:6379 ================================================ FILE: crates/sshx/Cargo.toml ================================================ [package] name = "sshx" version.workspace = true authors.workspace = true license.workspace = true description.workspace = true repository.workspace = true documentation.workspace = true keywords.workspace = true edition = "2021" [dependencies] aes = "0.8.3" ansi_term = "0.12.1" anyhow.workspace = true argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] } cfg-if = "1.0.0" clap.workspace = true ctr = "0.9.2" encoding_rs = "0.8.31" pin-project = "1.1.3" sshx-core.workspace = true tokio.workspace = true tokio-stream.workspace = true tonic.workspace = true tracing.workspace = true tracing-subscriber.workspace = true whoami = { version = "1.5.1", default-features = false } [target.'cfg(unix)'.dependencies] close_fds = "0.3.2" nix = { version = "0.27.1", features = ["ioctl", "process", "signal", "term"] } [target.'cfg(windows)'.dependencies] conpty = "0.7.0" ================================================ FILE: crates/sshx/examples/stdin_client.rs ================================================ use std::io::Read; use std::sync::Arc; use std::thread; use anyhow::Result; use sshx::terminal::{get_default_shell, Terminal}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::signal; use tokio::sync::mpsc; use tracing::{error, info, trace}; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt::init(); let shell = get_default_shell().await; info!(%shell, "using default shell"); let mut terminal = Terminal::new(&shell).await?; // Separate thread for reading from standard input. let (tx, mut rx) = mpsc::channel::>(16); thread::spawn(move || loop { let mut buf = [0u8; 256]; let n = std::io::stdin().read(&mut buf).unwrap(); if tx.blocking_send(buf[0..n].into()).is_err() { break; } }); let exit_signal = signal::ctrl_c(); tokio::pin!(exit_signal); loop { let mut buf = [0u8; 256]; tokio::select! { Some(bytes) = rx.recv() => { terminal.write_all(&bytes).await?; } result = terminal.read(&mut buf) => { let n = result?; io::stdout().write_all(&buf[..n]).await?; } result = &mut exit_signal => { if let Err(err) = result { error!(?err, "failed to listen for exit signal"); } trace!("gracefully exiting main"); break; } } } Ok(()) } ================================================ FILE: crates/sshx/src/controller.rs ================================================ //! Network gRPC client allowing server control of terminals. use std::collections::HashMap; use std::pin::pin; use anyhow::{Context, Result}; use sshx_core::proto::{ client_update::ClientMessage, server_update::ServerMessage, sshx_service_client::SshxServiceClient, ClientUpdate, CloseRequest, NewShell, OpenRequest, }; use sshx_core::{rand_alphanumeric, Sid}; use tokio::sync::mpsc; use tokio::task; use tokio::time::{self, Duration, Instant, MissedTickBehavior}; use tokio_stream::{wrappers::ReceiverStream, StreamExt}; use tonic::transport::Channel; use tracing::{debug, error, warn}; use crate::encrypt::Encrypt; use crate::runner::{Runner, ShellData}; /// Interval for sending empty heartbeat messages to the server. const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); /// Interval to automatically reestablish connections. const RECONNECT_INTERVAL: Duration = Duration::from_secs(60); /// Handles a single session's communication with the remote server. pub struct Controller { origin: String, runner: Runner, encrypt: Encrypt, encryption_key: String, name: String, token: String, url: String, write_url: Option, /// Channels with backpressure routing messages to each shell task. shells_tx: HashMap>, /// Channel shared with tasks to allow them to output client messages. output_tx: mpsc::Sender, /// Owned receiving end of the `output_tx` channel. output_rx: mpsc::Receiver, } impl Controller { /// Construct a new controller, connecting to the remote server. pub async fn new( origin: &str, name: &str, runner: Runner, enable_readers: bool, ) -> Result { debug!(%origin, "connecting to server"); let encryption_key = rand_alphanumeric(14); // 83.3 bits of entropy let kdf_task = { let encryption_key = encryption_key.clone(); task::spawn_blocking(move || Encrypt::new(&encryption_key)) }; let (write_password, kdf_write_password_task) = if enable_readers { let write_password = rand_alphanumeric(14); // 83.3 bits of entropy let task = { let write_password = write_password.clone(); task::spawn_blocking(move || Encrypt::new(&write_password)) }; (Some(write_password), Some(task)) } else { (None, None) }; let mut client = Self::connect(origin).await?; let encrypt = kdf_task.await?; let write_password_hash = if let Some(task) = kdf_write_password_task { Some(task.await?.zeros().into()) } else { None }; let req = OpenRequest { origin: origin.into(), encrypted_zeros: encrypt.zeros().into(), name: name.into(), write_password_hash, }; let mut resp = client.open(req).await?.into_inner(); resp.url = resp.url + "#" + &encryption_key; let write_url = if let Some(write_password) = write_password { Some(resp.url.clone() + "," + &write_password) } else { None }; let (output_tx, output_rx) = mpsc::channel(64); Ok(Self { origin: origin.into(), runner, encrypt, encryption_key, name: resp.name, token: resp.token, url: resp.url, write_url, shells_tx: HashMap::new(), output_tx, output_rx, }) } /// Create a new gRPC client to the HTTP(S) origin. /// /// This is used on reconnection to the server, since some replicas may be /// gracefully shutting down, which means connected clients need to start a /// new TCP handshake. async fn connect(origin: &str) -> Result, tonic::transport::Error> { SshxServiceClient::connect(String::from(origin)).await } /// Returns the name of the session. pub fn name(&self) -> &str { &self.name } /// Returns the URL of the session. pub fn url(&self) -> &str { &self.url } /// Returns the write URL of the session, if it exists. pub fn write_url(&self) -> Option<&str> { self.write_url.as_deref() } /// Returns the encryption key for this session, hidden from the server. pub fn encryption_key(&self) -> &str { &self.encryption_key } /// Run the controller forever, listening for requests from the server. pub async fn run(&mut self) -> ! { let mut last_retry = Instant::now(); let mut retries = 0; loop { if let Err(err) = self.try_channel().await { if last_retry.elapsed() >= Duration::from_secs(10) { retries = 0; } let secs = 2_u64.pow(retries.min(4)); error!(%err, "disconnected, retrying in {secs}s..."); time::sleep(Duration::from_secs(secs)).await; retries += 1; } last_retry = Instant::now(); } } /// Helper function used by `run()` that can return errors. async fn try_channel(&mut self) -> Result<()> { let (tx, rx) = mpsc::channel(16); let hello = ClientMessage::Hello(format!("{},{}", self.name, self.token)); send_msg(&tx, hello).await?; let mut client = Self::connect(&self.origin).await?; let resp = client.channel(ReceiverStream::new(rx)).await?; let mut messages = resp.into_inner(); // A stream of server messages. let mut interval = time::interval(HEARTBEAT_INTERVAL); interval.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut reconnect = pin!(time::sleep(RECONNECT_INTERVAL)); loop { let message = tokio::select! { _ = interval.tick() => { tx.send(ClientUpdate::default()).await?; continue; } msg = self.output_rx.recv() => { let msg = msg.context("unreachable: output_tx was closed?")?; send_msg(&tx, msg).await?; continue; } item = messages.next() => { item.context("server closed connection")?? .server_message .context("server message is missing")? } _ = &mut reconnect => { return Ok(()); // Reconnect to the server. } }; match message { ServerMessage::Input(input) => { let data = self.encrypt.segment(0x200000000, input.offset, &input.data); if let Some(sender) = self.shells_tx.get(&Sid(input.id)) { // This line applies backpressure if the shell task is overloaded. sender.send(ShellData::Data(data)).await.ok(); } else { warn!(%input.id, "received data for non-existing shell"); } } ServerMessage::CreateShell(new_shell) => { let id = Sid(new_shell.id); let center = (new_shell.x, new_shell.y); if !self.shells_tx.contains_key(&id) { self.spawn_shell_task(id, center); } else { warn!(%id, "server asked to create duplicate shell"); } } ServerMessage::CloseShell(id) => { // Closes the channel when it is dropped, notifying the task to shut down. self.shells_tx.remove(&Sid(id)); send_msg(&tx, ClientMessage::ClosedShell(id)).await?; } ServerMessage::Sync(seqnums) => { for (id, seq) in seqnums.map { if let Some(sender) = self.shells_tx.get(&Sid(id)) { sender.send(ShellData::Sync(seq)).await.ok(); } else { warn!(%id, "received sequence number for non-existing shell"); send_msg(&tx, ClientMessage::ClosedShell(id)).await?; } } } ServerMessage::Resize(msg) => { if let Some(sender) = self.shells_tx.get(&Sid(msg.id)) { sender.send(ShellData::Size(msg.rows, msg.cols)).await.ok(); } else { warn!(%msg.id, "received resize for non-existing shell"); } } ServerMessage::Ping(ts) => { // Echo back the timestamp, for stateless latency measurement. send_msg(&tx, ClientMessage::Pong(ts)).await?; } ServerMessage::Error(err) => { error!(?err, "error received from server"); } } } } /// Entry point to start a new terminal task on the client. fn spawn_shell_task(&mut self, id: Sid, center: (i32, i32)) { let (shell_tx, shell_rx) = mpsc::channel(16); let opt = self.shells_tx.insert(id, shell_tx); debug_assert!(opt.is_none(), "shell ID cannot be in existing tasks"); let runner = self.runner.clone(); let encrypt = self.encrypt.clone(); let output_tx = self.output_tx.clone(); tokio::spawn(async move { debug!(%id, "spawning new shell"); let new_shell = NewShell { id: id.0, x: center.0, y: center.1, }; if let Err(err) = output_tx.send(ClientMessage::CreatedShell(new_shell)).await { error!(%id, ?err, "failed to send shell creation message"); return; } if let Err(err) = runner.run(id, encrypt, shell_rx, output_tx.clone()).await { let err = ClientMessage::Error(err.to_string()); output_tx.send(err).await.ok(); } output_tx.send(ClientMessage::ClosedShell(id.0)).await.ok(); }); } /// Terminate this session gracefully. pub async fn close(&self) -> Result<()> { debug!("closing session"); let req = CloseRequest { name: self.name.clone(), token: self.token.clone(), }; let mut client = Self::connect(&self.origin).await?; client.close(req).await?; Ok(()) } } /// Attempt to send a client message over an update channel. async fn send_msg(tx: &mpsc::Sender, message: ClientMessage) -> Result<()> { let update = ClientUpdate { client_message: Some(message), }; tx.send(update) .await .context("failed to send message to server") } ================================================ FILE: crates/sshx/src/encrypt.rs ================================================ //! Encryption of byte streams based on a random key. use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; type Aes128Ctr64BE = ctr::Ctr64BE; // Note: The KDF salt is public, as it needs to be used from the web client. It // only exists to make rainbow table attacks less likely. const SALT: &str = "This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!"; /// Encrypts byte streams using the Argon2 hash of a random key. #[derive(Clone)] pub struct Encrypt { aes_key: [u8; 16], // 16-bit } impl Encrypt { /// Construct a new encryptor. pub fn new(key: &str) -> Self { use argon2::{Algorithm, Argon2, Params, Version}; // These parameters must match the browser implementation. let hasher = Argon2::new( Algorithm::Argon2id, Version::V0x13, Params::new(19 * 1024, 2, 1, Some(16)).unwrap(), ); let mut aes_key = [0; 16]; hasher .hash_password_into(key.as_bytes(), SALT.as_bytes(), &mut aes_key) .expect("failed to hash key with argon2"); Self { aes_key } } /// Get the encrypted zero block. pub fn zeros(&self) -> Vec { let mut zeros = [0; 16]; let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &zeros.into()); cipher.apply_keystream(&mut zeros); zeros.to_vec() } /// Encrypt a segment of data from a stream. /// /// Note that in CTR mode, the encryption operation is the same as the /// decryption operation. pub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Vec { assert_ne!(stream_num, 0, "stream number must be nonzero"); // security check let mut iv = [0; 16]; iv[0..8].copy_from_slice(&stream_num.to_be_bytes()); let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &iv.into()); let mut buf = data.to_vec(); cipher.seek(offset); cipher.apply_keystream(&mut buf); buf } } #[cfg(test)] mod tests { use super::Encrypt; #[test] fn make_encrypt() { let encrypt = Encrypt::new("test"); assert_eq!( encrypt.zeros(), [198, 3, 249, 238, 65, 10, 224, 98, 253, 73, 148, 1, 138, 3, 108, 143], ); } #[test] fn roundtrip_ctr() { let encrypt = Encrypt::new("this is a test key"); let data = b"hello world"; let encrypted = encrypt.segment(1, 0, data); assert_eq!(encrypted.len(), data.len()); let decrypted = encrypt.segment(1, 0, &encrypted); assert_eq!(decrypted, data); } #[test] fn matches_offset() { let encrypt = Encrypt::new("this is a test key"); let data = b"1st block.(16B)|2nd block......|3rd block"; let encrypted = encrypt.segment(1, 0, data); assert_eq!(encrypted.len(), data.len()); for i in 1..data.len() { let encrypted_suffix = encrypt.segment(1, i as u64, &data[i..]); assert_eq!(encrypted_suffix, &encrypted[i..]); } } #[test] #[should_panic] fn zero_stream_num() { let encrypt = Encrypt::new("this is a test key"); encrypt.segment(0, 0, b"hello world"); } } ================================================ FILE: crates/sshx/src/lib.rs ================================================ //! Library code for the sshx command-line client application. //! //! This crate does not forbid use of unsafe code because it needs to interact //! with operating-system APIs to access pseudoterminal (PTY) devices. #![deny(unsafe_code)] #![warn(missing_docs)] pub mod controller; pub mod encrypt; pub mod runner; pub mod terminal; ================================================ FILE: crates/sshx/src/main.rs ================================================ use std::process::ExitCode; use ansi_term::Color::{Cyan, Fixed, Green}; use anyhow::Result; use clap::Parser; use sshx::{controller::Controller, runner::Runner, terminal::get_default_shell}; use tokio::signal; use tracing::error; /// A secure web-based, collaborative terminal. #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] struct Args { /// Address of the remote sshx server. #[clap(long, default_value = "https://sshx.io", env = "SSHX_SERVER")] server: String, /// Local shell command to run in the terminal. #[clap(long)] shell: Option, /// Quiet mode, only prints the URL to stdout. #[clap(short, long)] quiet: bool, /// Session name displayed in the title (defaults to user@hostname). #[clap(long)] name: Option, /// Enable read-only access mode - generates separate URLs for viewers and /// editors. #[clap(long)] enable_readers: bool, } fn print_greeting(shell: &str, controller: &Controller) { let version_str = match option_env!("CARGO_PKG_VERSION") { Some(version) => format!("v{version}"), None => String::from("[dev]"), }; if let Some(write_url) = controller.write_url() { println!( r#" {sshx} {version} {arr} Read-only link: {link_v} {arr} Writable link: {link_e} {arr} Shell: {shell_v} "#, sshx = Green.bold().paint("sshx"), version = Green.paint(&version_str), arr = Green.paint("➜"), link_v = Cyan.underline().paint(controller.url()), link_e = Cyan.underline().paint(write_url), shell_v = Fixed(8).paint(shell), ); } else { println!( r#" {sshx} {version} {arr} Link: {link_v} {arr} Shell: {shell_v} "#, sshx = Green.bold().paint("sshx"), version = Green.paint(&version_str), arr = Green.paint("➜"), link_v = Cyan.underline().paint(controller.url()), shell_v = Fixed(8).paint(shell), ); } } #[tokio::main] async fn start(args: Args) -> Result<()> { let shell = match args.shell { Some(shell) => shell, None => get_default_shell().await, }; let name = args.name.unwrap_or_else(|| { let mut name = whoami::username(); if let Ok(host) = whoami::fallible::hostname() { // Trim domain information like .lan or .local let host = host.split('.').next().unwrap_or(&host); name += "@"; name += host; } name }); let runner = Runner::Shell(shell.clone()); let mut controller = Controller::new(&args.server, &name, runner, args.enable_readers).await?; if args.quiet { if let Some(write_url) = controller.write_url() { println!("{}", write_url); } else { println!("{}", controller.url()); } } else { print_greeting(&shell, &controller); } let exit_signal = signal::ctrl_c(); tokio::pin!(exit_signal); tokio::select! { _ = controller.run() => unreachable!(), Ok(()) = &mut exit_signal => (), }; controller.close().await?; Ok(()) } fn main() -> ExitCode { let args = Args::parse(); let default_level = if args.quiet { "error" } else { "info" }; tracing_subscriber::fmt() .with_env_filter(std::env::var("RUST_LOG").unwrap_or(default_level.into())) .with_writer(std::io::stderr) .init(); match start(args) { Ok(()) => ExitCode::SUCCESS, Err(err) => { error!("{err:?}"); ExitCode::FAILURE } } } ================================================ FILE: crates/sshx/src/runner.rs ================================================ //! Defines tasks that control the behavior of a single shell in the client. use anyhow::Result; use encoding_rs::{CoderResult, UTF_8}; use sshx_core::proto::{client_update::ClientMessage, TerminalData}; use sshx_core::Sid; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::mpsc, }; use crate::encrypt::Encrypt; use crate::terminal::Terminal; const CONTENT_CHUNK_SIZE: usize = 1 << 16; // Send at most this many bytes at a time. const CONTENT_ROLLING_BYTES: usize = 8 << 20; // Store at least this much content. const CONTENT_PRUNE_BYTES: usize = 12 << 20; // Prune when we exceed this length. /// Variants of terminal behavior that are used by the controller. #[derive(Debug, Clone)] pub enum Runner { /// Spawns the specified shell as a subprocess, forwarding PTYs. Shell(String), /// Mock runner that only echos its input, useful for testing. Echo, } /// Internal message routed to shell runners. pub enum ShellData { /// Sequence of input bytes from the server. Data(Vec), /// Information about the server's current sequence number. Sync(u64), /// Resize the shell to a different number of rows and columns. Size(u32, u32), } impl Runner { /// Asynchronous task to run a single shell with process I/O. pub async fn run( &self, id: Sid, encrypt: Encrypt, shell_rx: mpsc::Receiver, output_tx: mpsc::Sender, ) -> Result<()> { match self { Self::Shell(shell) => shell_task(id, encrypt, shell, shell_rx, output_tx).await, Self::Echo => echo_task(id, encrypt, shell_rx, output_tx).await, } } } /// Asynchronous task handling a single shell within the session. async fn shell_task( id: Sid, encrypt: Encrypt, shell: &str, mut shell_rx: mpsc::Receiver, output_tx: mpsc::Sender, ) -> Result<()> { let mut term = Terminal::new(shell).await?; term.set_winsize(24, 80)?; let mut content = String::new(); // content from the terminal let mut content_offset = 0; // bytes before the first character of `content` let mut decoder = UTF_8.new_decoder(); // UTF-8 streaming decoder let mut seq = 0; // our log of the server's sequence number let mut seq_outdated = 0; // number of times seq has been outdated let mut buf = [0u8; 4096]; // buffer for reading let mut finished = false; // set when this is done while !finished { tokio::select! { result = term.read(&mut buf) => { let n = result?; if n == 0 { finished = true; } else { content.reserve(decoder.max_utf8_buffer_length(n).unwrap()); let (result, _, _) = decoder.decode_to_string(&buf[..n], &mut content, false); debug_assert!(result == CoderResult::InputEmpty); } } item = shell_rx.recv() => { match item { Some(ShellData::Data(data)) => { term.write_all(&data).await?; } Some(ShellData::Sync(seq2)) => { if seq2 < seq as u64 { seq_outdated += 1; if seq_outdated >= 3 { seq = seq2 as usize; } } } Some(ShellData::Size(rows, cols)) => { term.set_winsize(rows as u16, cols as u16)?; } None => finished = true, // Server closed this shell. } } } if finished { content.reserve(decoder.max_utf8_buffer_length(0).unwrap()); let (result, _, _) = decoder.decode_to_string(&[], &mut content, true); debug_assert!(result == CoderResult::InputEmpty); } // Send data if the server has fallen behind. if content_offset + content.len() > seq { let start = prev_char_boundary(&content, seq - content_offset); let end = prev_char_boundary(&content, (start + CONTENT_CHUNK_SIZE).min(content.len())); let data = encrypt.segment( 0x100000000 | id.0 as u64, // stream number (content_offset + start) as u64, &content.as_bytes()[start..end], ); let data = TerminalData { id: id.0, data: data.into(), seq: (content_offset + start) as u64, }; output_tx.send(ClientMessage::Data(data)).await?; seq = content_offset + end; seq_outdated = 0; } if content.len() > CONTENT_PRUNE_BYTES && seq - CONTENT_ROLLING_BYTES > content_offset { let pruned = (seq - CONTENT_ROLLING_BYTES) - content_offset; let pruned = prev_char_boundary(&content, pruned); content_offset += pruned; content.drain(..pruned); } } Ok(()) } /// Find the last char boundary before an index in O(1) time. fn prev_char_boundary(s: &str, i: usize) -> usize { (0..=i) .rev() .find(|&j| s.is_char_boundary(j)) .expect("no previous char boundary") } async fn echo_task( id: Sid, encrypt: Encrypt, mut shell_rx: mpsc::Receiver, output_tx: mpsc::Sender, ) -> Result<()> { let mut seq = 0; while let Some(item) = shell_rx.recv().await { match item { ShellData::Data(data) => { let msg = String::from_utf8_lossy(&data); let term_data = TerminalData { id: id.0, data: encrypt .segment(0x100000000 | id.0 as u64, seq, msg.as_bytes()) .into(), seq, }; output_tx.send(ClientMessage::Data(term_data)).await?; seq += msg.len() as u64; } ShellData::Sync(_) => (), ShellData::Size(_, _) => (), } } Ok(()) } ================================================ FILE: crates/sshx/src/terminal/unix.rs ================================================ use std::convert::Infallible; use std::env; use std::ffi::{CStr, CString}; use std::os::fd::{AsRawFd, RawFd}; use std::pin::Pin; use std::task::{Context, Poll}; use anyhow::Result; use close_fds::CloseFdsBuilder; use nix::errno::Errno; use nix::libc::{login_tty, TIOCGWINSZ, TIOCSWINSZ}; use nix::pty::{self, Winsize}; use nix::sys::signal::{kill, Signal::SIGKILL}; use nix::sys::wait::waitpid; use nix::unistd::{execvp, fork, ForkResult, Pid}; use pin_project::{pin_project, pinned_drop}; use tokio::fs::{self, File}; use tokio::io::{self, AsyncRead, AsyncWrite}; use tracing::{instrument, trace}; /// Returns the default shell on this system. pub async fn get_default_shell() -> String { if let Ok(shell) = env::var("SHELL") { if !shell.is_empty() { return shell; } } for shell in [ "/bin/bash", "/bin/sh", "/usr/local/bin/bash", "/usr/local/bin/sh", ] { if fs::metadata(shell).await.is_ok() { return shell.to_string(); } } String::from("sh") } /// An object that stores the state for a terminal session. #[pin_project(PinnedDrop)] pub struct Terminal { child: Pid, #[pin] master_read: File, #[pin] master_write: File, } impl Terminal { /// Create a new terminal, with attached PTY. #[instrument] pub async fn new(shell: &str) -> Result { let result = pty::openpty(None, None)?; // The slave file descriptor was created by openpty() and is forked here. let child = Self::fork_child(shell, result.slave.as_raw_fd())?; // We need to clone the file object to prevent livelocks in Tokio, when multiple // reads and writes happen concurrently on the same file descriptor. This is a // current limitation of how the `tokio::fs::File` struct is implemented, due to // its blocking I/O on a separate thread. let master_read = File::from(std::fs::File::from(result.master)); let master_write = master_read.try_clone().await?; trace!(%child, "creating new terminal"); Ok(Self { child, master_read, master_write, }) } /// Entry point for the child process, which spawns a shell. fn fork_child(shell: &str, slave_port: RawFd) -> Result { let shell = CString::new(shell.to_owned())?; // Safety: This does not use any async-signal-unsafe operations in the child // branch, such as memory allocation. match unsafe { fork() }? { ForkResult::Parent { child } => Ok(child), ForkResult::Child => match Self::execv_child(&shell, slave_port) { Ok(infallible) => match infallible {}, Err(_) => std::process::exit(1), }, } } fn execv_child(shell: &CStr, slave_port: RawFd) -> Result { // Safety: The slave file descriptor was created by openpty(). Errno::result(unsafe { login_tty(slave_port) })?; // Safety: This is called immediately before an execv(), and there are no other // threads in this process to interact with its file descriptor table. unsafe { CloseFdsBuilder::new().closefrom(3) }; // Set terminal environment variables appropriately. env::set_var("TERM", "xterm-256color"); env::set_var("COLORTERM", "truecolor"); env::set_var("TERM_PROGRAM", "sshx"); env::remove_var("TERM_PROGRAM_VERSION"); // Start the process. execvp(shell, &[shell]) } /// Get the window size of the TTY. pub fn get_winsize(&self) -> Result<(u16, u16)> { nix::ioctl_read_bad!(ioctl_get_winsize, TIOCGWINSZ, Winsize); let mut winsize = make_winsize(0, 0); // Safety: The master file descriptor was created by openpty(). unsafe { ioctl_get_winsize(self.master_read.as_raw_fd(), &mut winsize) }?; Ok((winsize.ws_row, winsize.ws_col)) } /// Set the window size of the TTY. pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> { nix::ioctl_write_ptr_bad!(ioctl_set_winsize, TIOCSWINSZ, Winsize); let winsize = make_winsize(rows, cols); // Safety: The master file descriptor was created by openpty(). unsafe { ioctl_set_winsize(self.master_read.as_raw_fd(), &winsize) }?; Ok(()) } } // Redirect terminal reads to the read file object. impl AsyncRead for Terminal { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut io::ReadBuf<'_>, ) -> Poll> { self.project().master_read.poll_read(cx, buf) } } // Redirect terminal writes to the write file object. impl AsyncWrite for Terminal { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { self.project().master_write.poll_write(cx, buf) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().master_write.poll_flush(cx) } fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().master_write.poll_shutdown(cx) } } #[pinned_drop] impl PinnedDrop for Terminal { fn drop(self: Pin<&mut Self>) { let this = self.project(); let child = *this.child; trace!(%child, "dropping terminal"); // Kill the child process on closure so that it doesn't keep running. kill(child, SIGKILL).ok(); // Reap the zombie process in a background thread. std::thread::spawn(move || { waitpid(child, None).ok(); }); } } fn make_winsize(rows: u16, cols: u16) -> Winsize { Winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, // ignored ws_ypixel: 0, // ignored } } ================================================ FILE: crates/sshx/src/terminal/windows.rs ================================================ use std::pin::Pin; use std::process::Command; use std::task::Context; use std::task::Poll; use anyhow::Result; use pin_project::{pin_project, pinned_drop}; use tokio::fs::{self, File}; use tokio::io::{self, AsyncRead, AsyncWrite}; use tracing::instrument; /// Returns the default shell on this system. /// /// For Windows, this is implemented currently to just look for shells at a /// couple locations. If it fails, it returns `cmd.exe`. /// /// Note: I can't get `powershell.exe` to work with ConPTY, since it returns /// error 8009001d. There's some magic environment variables that need to be set /// for Powershell to launch. This is why I don't typically use Windows! pub async fn get_default_shell() -> String { for shell in [ "C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Windows\\System32\\cmd.exe", ] { if fs::metadata(shell).await.is_ok() { return shell.to_string(); } } String::from("cmd.exe") } /// An object that stores the state for a terminal session. #[pin_project(PinnedDrop)] pub struct Terminal { child: conpty::Process, #[pin] reader: File, #[pin] writer: File, winsize: (u16, u16), } impl Terminal { /// Create a new terminal, with attached PTY. #[instrument] pub async fn new(shell: &str) -> Result { let mut command = Command::new(shell); // Set terminal environment variables appropriately. command.env("TERM", "xterm-256color"); command.env("COLORTERM", "truecolor"); command.env("TERM_PROGRAM", "sshx"); command.env_remove("TERM_PROGRAM_VERSION"); let mut child = tokio::task::spawn_blocking(move || conpty::Process::spawn(command)).await??; let reader = File::from_std(child.output()?.into()); let writer = File::from_std(child.input()?.into()); Ok(Self { child, reader, writer, winsize: (0, 0), }) } /// Get the window size of the TTY. pub fn get_winsize(&self) -> Result<(u16, u16)> { Ok(self.winsize) } /// Set the window size of the TTY. pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> { let rows_i16 = rows.min(i16::MAX as u16) as i16; let cols_i16 = cols.min(i16::MAX as u16) as i16; self.child.resize(cols_i16, rows_i16)?; // Note argument order self.winsize = (rows, cols); Ok(()) } } // Redirect terminal reads to the read file object. impl AsyncRead for Terminal { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut io::ReadBuf<'_>, ) -> Poll> { self.project().reader.poll_read(cx, buf) } } // Redirect terminal writes to the write file object. impl AsyncWrite for Terminal { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { self.project().writer.poll_write(cx, buf) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().writer.poll_flush(cx) } fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().writer.poll_shutdown(cx) } } #[pinned_drop] impl PinnedDrop for Terminal { fn drop(self: Pin<&mut Self>) { let this = self.project(); this.child.exit(0).ok(); } } ================================================ FILE: crates/sshx/src/terminal.rs ================================================ //! Terminal driver, which communicates with a shell subprocess through PTY. #![allow(unsafe_code)] cfg_if::cfg_if! { if #[cfg(unix)] { mod unix; pub use unix::{get_default_shell, Terminal}; } else if #[cfg(windows)] { mod windows; pub use windows::{get_default_shell, Terminal}; } else { compile_error!("unsupported platform for terminal driver"); } } #[cfg(test)] mod tests { use anyhow::Result; use super::Terminal; #[tokio::test] async fn winsize() -> Result<()> { let shell = if cfg!(unix) { "/bin/sh" } else { "cmd.exe" }; let mut terminal = Terminal::new(shell).await?; assert_eq!(terminal.get_winsize()?, (0, 0)); terminal.set_winsize(120, 72)?; assert_eq!(terminal.get_winsize()?, (120, 72)); Ok(()) } } ================================================ FILE: crates/sshx-core/Cargo.toml ================================================ [package] name = "sshx-core" version.workspace = true authors.workspace = true license.workspace = true description.workspace = true repository.workspace = true documentation.workspace = true keywords.workspace = true edition = "2021" [dependencies] prost.workspace = true rand.workspace = true serde.workspace = true tonic.workspace = true [build-dependencies] tonic-build.workspace = true ================================================ FILE: crates/sshx-core/build.rs ================================================ use std::{env, path::PathBuf}; fn main() -> Result<(), Box> { let descriptor_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("sshx.bin"); tonic_build::configure() .file_descriptor_set_path(descriptor_path) .bytes(["."]) .compile_protos(&["proto/sshx.proto"], &["proto/"])?; Ok(()) } ================================================ FILE: crates/sshx-core/proto/sshx.proto ================================================ // This file contains the service definition for sshx, used by the client to // communicate their terminal state over gRPC. syntax = "proto3"; package sshx; service SshxService { // Create a new SSH session for a given computer. rpc Open(OpenRequest) returns (OpenResponse); // Stream real-time commands and terminal outputs to the session. rpc Channel(stream ClientUpdate) returns (stream ServerUpdate); // Gracefully shut down an existing SSH session. rpc Close(CloseRequest) returns (CloseResponse); } // Details of bytes exchanged with the terminal. message TerminalData { uint32 id = 1; // ID of the shell. bytes data = 2; // Encrypted, UTF-8 terminal data. uint64 seq = 3; // Sequence number of the first byte. } // Details of bytes input to the terminal (not necessarily valid UTF-8). message TerminalInput { uint32 id = 1; // ID of the shell. bytes data = 2; // Encrypted binary sequence of terminal data. uint64 offset = 3; // Offset of the first byte for encryption. } // Pair of a terminal ID and its associated size. message TerminalSize { uint32 id = 1; // ID of the shell. uint32 rows = 2; // Number of rows for the terminal. uint32 cols = 3; // Number of columns for the terminal. } // Request to open an sshx session. message OpenRequest { string origin = 1; // Web origin of the server. bytes encrypted_zeros = 2; // Encrypted zero block, for client verification. string name = 3; // Name of the session (user@hostname). optional bytes write_password_hash = 4; // Hashed write password, if read-only mode is enabled. } // Details of a newly-created sshx session. message OpenResponse { string name = 1; // Name of the session. string token = 2; // Signed verification token for the client. string url = 3; // Public web URL to view the session. } // Sequence numbers for all active shells, used for synchronization. message SequenceNumbers { map map = 1; // Active shells and their sequence numbers. } // Data for a new shell. message NewShell { uint32 id = 1; // ID of the shell. int32 x = 2; // X position of the shell. int32 y = 3; // Y position of the shell. } // Bidirectional streaming update from the client. message ClientUpdate { oneof client_message { string hello = 1; // First stream message: "name,token". TerminalData data = 2; // Stream data from the terminal. NewShell created_shell = 3; // Acknowledge that a new shell was created. uint32 closed_shell = 4; // Acknowledge that a shell was closed. fixed64 pong = 14; // Response for latency measurement. string error = 15; } } // Bidirectional streaming update from the server. message ServerUpdate { oneof server_message { TerminalInput input = 1; // Remote input bytes, received from the user. NewShell create_shell = 2; // ID of a new shell. uint32 close_shell = 3; // ID of a shell to close. SequenceNumbers sync = 4; // Periodic sequence number sync. TerminalSize resize = 5; // Resize a terminal window. fixed64 ping = 14; // Request a pong, with the timestamp. string error = 15; } } // Request to stop a sshx session gracefully. message CloseRequest { string name = 1; // Name of the session to terminate. string token = 2; // Session verification token. } // Server response to closing a session. message CloseResponse {} // Snapshot of a session, used to restore state for persistence across servers. message SerializedSession { bytes encrypted_zeros = 1; map shells = 2; uint32 next_sid = 3; uint32 next_uid = 4; string name = 5; optional bytes write_password_hash = 6; } message SerializedShell { uint64 seqnum = 1; repeated bytes data = 2; uint64 chunk_offset = 3; uint64 byte_offset = 4; bool closed = 5; int32 winsize_x = 6; int32 winsize_y = 7; uint32 winsize_rows = 8; uint32 winsize_cols = 9; } ================================================ FILE: crates/sshx-core/src/lib.rs ================================================ //! The core crate for shared code used in the sshx application. #![forbid(unsafe_code)] #![warn(missing_docs)] use std::fmt::Display; use std::sync::atomic::{AtomicU32, Ordering}; use serde::{Deserialize, Serialize}; /// Protocol buffer and gRPC definitions, automatically generated by Tonic. #[allow(missing_docs, non_snake_case)] #[allow(clippy::derive_partial_eq_without_eq)] pub mod proto { tonic::include_proto!("sshx"); /// File descriptor set used for gRPC reflection. pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("sshx"); } /// Generate a cryptographically-secure, random alphanumeric value. pub fn rand_alphanumeric(len: usize) -> String { use rand::{distributions::Alphanumeric, thread_rng, Rng}; thread_rng() .sample_iter(Alphanumeric) .take(len) .map(char::from) .collect() } /// Unique identifier for a shell within the session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct Sid(pub u32); impl Display for Sid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Unique identifier for a user within the session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct Uid(pub u32); impl Display for Uid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// A counter for generating unique identifiers. #[derive(Debug)] pub struct IdCounter { next_sid: AtomicU32, next_uid: AtomicU32, } impl Default for IdCounter { fn default() -> Self { Self { next_sid: AtomicU32::new(1), next_uid: AtomicU32::new(1), } } } impl IdCounter { /// Returns the next unique shell ID. pub fn next_sid(&self) -> Sid { Sid(self.next_sid.fetch_add(1, Ordering::Relaxed)) } /// Returns the next unique user ID. pub fn next_uid(&self) -> Uid { Uid(self.next_uid.fetch_add(1, Ordering::Relaxed)) } /// Return the current internal values of the counter. pub fn get_current_values(&self) -> (Sid, Uid) { ( Sid(self.next_sid.load(Ordering::Relaxed)), Uid(self.next_uid.load(Ordering::Relaxed)), ) } /// Set the internal values of the counter. pub fn set_current_values(&self, sid: Sid, uid: Uid) { self.next_sid.store(sid.0, Ordering::Relaxed); self.next_uid.store(uid.0, Ordering::Relaxed); } } ================================================ FILE: crates/sshx-server/Cargo.toml ================================================ [package] name = "sshx-server" version.workspace = true authors.workspace = true license.workspace = true description.workspace = true repository.workspace = true documentation.workspace = true keywords.workspace = true edition = "2021" [dependencies] anyhow.workspace = true async-channel = "1.9.0" async-stream = "0.3.5" axum = { version = "0.8.1", features = ["http2", "ws"] } base64 = "0.21.4" bytes = { version = "1.5.0", features = ["serde"] } ciborium = "0.2.1" clap.workspace = true dashmap = "5.5.3" deadpool = "0.12.2" deadpool-redis = "0.18.0" futures-util = { version = "0.3.28", features = ["sink"] } hmac = "0.12.1" http = "1.2.0" parking_lot = "0.12.1" prost.workspace = true rand.workspace = true redis = { version = "0.27.6", features = ["tokio-rustls-comp", "tls-rustls-webpki-roots"] } serde.workspace = true sha2 = "0.10.7" sshx-core.workspace = true subtle = "2.5.0" tokio.workspace = true tokio-stream.workspace = true tokio-tungstenite = "0.26.1" tonic.workspace = true tonic-reflection.workspace = true tower = { version = "0.4.13", features = ["steer"] } tower-http = { version = "0.6.2", features = ["fs", "redirect", "trace"] } tracing.workspace = true tracing-subscriber.workspace = true zstd = "0.12.4" [dev-dependencies] reqwest = { version = "0.12.12", default-features = false, features = ["rustls-tls"] } sshx = { path = "../sshx" } ================================================ FILE: crates/sshx-server/src/grpc.rs ================================================ //! Defines gRPC routes and application request logic. use std::sync::Arc; use std::time::{Duration, SystemTime}; use base64::prelude::{Engine as _, BASE64_STANDARD}; use hmac::Mac; use sshx_core::proto::{ client_update::ClientMessage, server_update::ServerMessage, sshx_service_server::SshxService, ClientUpdate, CloseRequest, CloseResponse, OpenRequest, OpenResponse, ServerUpdate, }; use sshx_core::{rand_alphanumeric, Sid}; use tokio::sync::mpsc; use tokio::time::{self, MissedTickBehavior}; use tokio_stream::{wrappers::ReceiverStream, StreamExt}; use tonic::{Request, Response, Status, Streaming}; use tracing::{error, info, warn}; use crate::session::{Metadata, Session}; use crate::ServerState; /// Interval for synchronizing sequence numbers with the client. pub const SYNC_INTERVAL: Duration = Duration::from_secs(5); /// Interval for measuring client latency. pub const PING_INTERVAL: Duration = Duration::from_secs(2); /// Server that handles gRPC requests from the sshx command-line client. #[derive(Clone)] pub struct GrpcServer(Arc); impl GrpcServer { /// Construct a new [`GrpcServer`] instance with associated state. pub fn new(state: Arc) -> Self { Self(state) } } type RR = Result, Status>; #[tonic::async_trait] impl SshxService for GrpcServer { type ChannelStream = ReceiverStream>; async fn open(&self, request: Request) -> RR { let request = request.into_inner(); let origin = self.0.override_origin().unwrap_or(request.origin); if origin.is_empty() { return Err(Status::invalid_argument("origin is empty")); } let name = rand_alphanumeric(10); info!(%name, "creating new session"); match self.0.lookup(&name) { Some(_) => return Err(Status::already_exists("generated duplicate ID")), None => { let metadata = Metadata { encrypted_zeros: request.encrypted_zeros, name: request.name, write_password_hash: request.write_password_hash, }; self.0.insert(&name, Arc::new(Session::new(metadata))); } }; let token = self.0.mac().chain_update(&name).finalize(); let url = format!("{origin}/s/{name}"); Ok(Response::new(OpenResponse { name, token: BASE64_STANDARD.encode(token.into_bytes()), url, })) } async fn channel(&self, request: Request>) -> RR { let mut stream = request.into_inner(); let first_update = match stream.next().await { Some(result) => result?, None => return Err(Status::invalid_argument("missing first message")), }; let session_name = match first_update.client_message { Some(ClientMessage::Hello(hello)) => { let (name, token) = hello .split_once(',') .ok_or_else(|| Status::invalid_argument("missing name and token"))?; validate_token(self.0.mac(), name, token)?; name.to_string() } _ => return Err(Status::invalid_argument("invalid first message")), }; let session = match self.0.backend_connect(&session_name).await { Ok(Some(session)) => session, Ok(None) => return Err(Status::not_found("session not found")), Err(err) => { error!(?err, "failed to connect to backend session"); return Err(Status::internal(err.to_string())); } }; // We now spawn an asynchronous task that sends updates to the client. Note that // when this task finishes, the sender end is dropped, so the receiver is // automatically closed. let (tx, rx) = mpsc::channel(16); tokio::spawn(async move { if let Err(err) = handle_streaming(&tx, &session, stream).await { warn!(?err, "connection exiting early due to an error"); } }); Ok(Response::new(ReceiverStream::new(rx))) } async fn close(&self, request: Request) -> RR { let request = request.into_inner(); validate_token(self.0.mac(), &request.name, &request.token)?; info!("closing session {}", request.name); if let Err(err) = self.0.close_session(&request.name).await { error!(?err, "failed to close session {}", request.name); return Err(Status::internal(err.to_string())); } Ok(Response::new(CloseResponse {})) } } /// Validate the client token for a session. #[allow(clippy::result_large_err)] fn validate_token(mac: impl Mac, name: &str, token: &str) -> tonic::Result<()> { if let Ok(token) = BASE64_STANDARD.decode(token) { if mac.chain_update(name).verify_slice(&token).is_ok() { return Ok(()); } } Err(Status::unauthenticated("invalid token")) } type ServerTx = mpsc::Sender>; /// Handle bidirectional streaming messages RPC messages. async fn handle_streaming( tx: &ServerTx, session: &Session, mut stream: Streaming, ) -> Result<(), &'static str> { let mut sync_interval = time::interval(SYNC_INTERVAL); sync_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut ping_interval = time::interval(PING_INTERVAL); ping_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); loop { tokio::select! { // Send periodic sync messages to the client. _ = sync_interval.tick() => { let msg = ServerMessage::Sync(session.sequence_numbers()); if !send_msg(tx, msg).await { return Err("failed to send sync message"); } } // Send periodic pings to the client. _ = ping_interval.tick() => { send_msg(tx, ServerMessage::Ping(get_time_ms())).await; } // Send buffered server updates to the client. Ok(msg) = session.update_rx().recv() => { if !send_msg(tx, msg).await { return Err("failed to send update message"); } } // Handle incoming client messages. maybe_update = stream.next() => { if let Some(Ok(update)) = maybe_update { if !handle_update(tx, session, update).await { return Err("error responding to client update"); } } else { // The client has hung up on their end. return Ok(()); } } // Exit on a session shutdown signal. _ = session.terminated() => { let msg = String::from("disconnecting because session is closed"); send_msg(tx, ServerMessage::Error(msg)).await; return Ok(()); } }; } } /// Handles a singe update from the client. Returns `true` on success. async fn handle_update(tx: &ServerTx, session: &Session, update: ClientUpdate) -> bool { session.access(); match update.client_message { Some(ClientMessage::Hello(_)) => { return send_err(tx, "unexpected hello".into()).await; } Some(ClientMessage::Data(data)) => { if let Err(err) = session.add_data(Sid(data.id), data.data, data.seq) { return send_err(tx, format!("add data: {:?}", err)).await; } } Some(ClientMessage::CreatedShell(new_shell)) => { let id = Sid(new_shell.id); let center = (new_shell.x, new_shell.y); if let Err(err) = session.add_shell(id, center) { return send_err(tx, format!("add shell: {:?}", err)).await; } } Some(ClientMessage::ClosedShell(id)) => { if let Err(err) = session.close_shell(Sid(id)) { return send_err(tx, format!("close shell: {:?}", err)).await; } } Some(ClientMessage::Pong(ts)) => { let latency = get_time_ms().saturating_sub(ts); session.send_latency_measurement(latency); } Some(ClientMessage::Error(err)) => { // TODO: Propagate these errors to listeners on the web interface? error!(?err, "error received from client"); } None => (), // Heartbeat message, ignored. } true } /// Attempt to send a server message to the client. async fn send_msg(tx: &ServerTx, message: ServerMessage) -> bool { let update = Ok(ServerUpdate { server_message: Some(message), }); tx.send(update).await.is_ok() } /// Attempt to send an error string to the client. async fn send_err(tx: &ServerTx, err: String) -> bool { send_msg(tx, ServerMessage::Error(err)).await } fn get_time_ms() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("system time is before the UNIX epoch") .as_millis() as u64 } ================================================ FILE: crates/sshx-server/src/lib.rs ================================================ //! The sshx server, which coordinates terminal sharing. //! //! Requests are communicated to the server via gRPC (for command-line sharing //! clients) and WebSocket connections (for web listeners). The server is built //! using a hybrid Hyper service, split between a Tonic gRPC handler and an Axum //! web listener. //! //! Most web requests are routed directly to static files located in the //! `build/` folder relative to where this binary is running, allowing the //! frontend to be separately developed from the server. #![forbid(unsafe_code)] #![warn(missing_docs)] use std::{fmt::Debug, net::SocketAddr, sync::Arc}; use anyhow::Result; use axum::serve::{Listener, ListenerExt}; use tokio::net::TcpListener; use tracing::debug; use utils::Shutdown; use crate::state::ServerState; pub mod grpc; mod listen; pub mod session; pub mod state; pub mod utils; pub mod web; /// Options when constructing the application server. #[derive(Clone, Debug, Default)] #[non_exhaustive] pub struct ServerOptions { /// Secret used for signing tokens. Set randomly if not provided. pub secret: Option, /// Override the origin returned for the Open() RPC. pub override_origin: Option, /// URL of the Redis server that stores session data. pub redis_url: Option, /// Hostname of this server, if running multiple servers. pub host: Option, } /// Stateful object that manages the sshx server, with graceful termination. pub struct Server { state: Arc, shutdown: Shutdown, } impl Server { /// Create a new application server, but do not listen for connections yet. pub fn new(options: ServerOptions) -> Result { Ok(Self { state: Arc::new(ServerState::new(options)?), shutdown: Shutdown::new(), }) } /// Returns the server's state object. pub fn state(&self) -> Arc { Arc::clone(&self.state) } /// Run the application server, listening on a stream of connections. pub async fn listen(&self, listener: L) -> Result<()> where L: Listener, L::Addr: Debug, { let state = self.state.clone(); let terminated = self.shutdown.wait(); tokio::spawn(async move { let background_tasks = futures_util::future::join( state.listen_for_transfers(), state.close_old_sessions(), ); tokio::select! { _ = terminated => {} _ = background_tasks => {} } }); listen::start_server(self.state(), listener, self.shutdown.wait()).await } /// Convenience function to call [`Server::listen`] bound to a TCP address. /// /// This also sets `TCP_NODELAY` on the incoming connections for performance /// reasons, as a reasonable default. pub async fn bind(&self, addr: &SocketAddr) -> Result<()> { let listener = TcpListener::bind(addr).await?.tap_io(|tcp_stream| { if let Err(err) = tcp_stream.set_nodelay(true) { debug!("failed to set TCP_NODELAY on incoming connection: {err:#}"); } }); self.listen(listener).await } /// Send a graceful shutdown signal to the server. pub fn shutdown(&self) { // Stop receiving new network connections. self.shutdown.shutdown(); // Terminate each of the existing sessions. self.state.shutdown(); } } ================================================ FILE: crates/sshx-server/src/listen.rs ================================================ use std::{fmt::Debug, future::Future, sync::Arc}; use anyhow::Result; use axum::body::Body; use axum::serve::Listener; use http::{header::CONTENT_TYPE, Request}; use sshx_core::proto::{sshx_service_server::SshxServiceServer, FILE_DESCRIPTOR_SET}; use tonic::service::Routes as TonicRoutes; use tower::{make::Shared, steer::Steer, ServiceExt}; use tower_http::trace::TraceLayer; use crate::{grpc::GrpcServer, web, ServerState}; /// Bind and listen from the application, with a state and termination signal. /// /// This internal method is responsible for multiplexing the HTTP and gRPC /// servers onto a single, consolidated `hyper` service. pub(crate) async fn start_server( state: Arc, listener: L, signal: impl Future + Send + 'static, ) -> Result<()> where L: Listener, L::Addr: Debug, { let http_service = web::app() .with_state(state.clone()) .layer(TraceLayer::new_for_http()) .into_service() .boxed_clone(); let grpc_service = TonicRoutes::default() .add_service(SshxServiceServer::new(GrpcServer::new(state))) .add_service( tonic_reflection::server::Builder::configure() .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) .build_v1()?, ) .into_axum_router() .layer(TraceLayer::new_for_grpc()) .into_service() // This type conversion is necessary because Tonic 0.12 uses Axum 0.7, so its `axum::Router` // and `axum::Body` are based on an older `axum_core` version. .map_response(|r| r.map(Body::new)) .boxed_clone(); let svc = Steer::new( [http_service, grpc_service], |req: &Request, _services: &[_]| { let headers = req.headers(); match headers.get(CONTENT_TYPE) { Some(content) if content == "application/grpc" => 1, _ => 0, } }, ); let make_svc = Shared::new(svc); axum::serve(listener, make_svc) .with_graceful_shutdown(signal) .await?; Ok(()) } ================================================ FILE: crates/sshx-server/src/main.rs ================================================ use std::{ net::{IpAddr, SocketAddr}, process::ExitCode, }; use anyhow::Result; use clap::Parser; use sshx_server::{Server, ServerOptions}; use tokio::signal::unix::{signal, SignalKind}; use tracing::{error, info}; /// The sshx server CLI interface. #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] struct Args { /// Specify port to listen on. #[clap(long, default_value_t = 8051)] port: u16, /// Which IP address or network interface to listen on. #[clap(long, value_parser, default_value = "::1")] listen: IpAddr, /// Secret used for signing session tokens. #[clap(long, env = "SSHX_SECRET")] secret: Option, /// Override the origin URL returned by the Open() RPC. #[clap(long)] override_origin: Option, /// URL of the Redis server that stores session data. #[clap(long, env = "SSHX_REDIS_URL")] redis_url: Option, /// Hostname of this server, if running multiple servers. #[clap(long)] host: Option, } #[tokio::main] async fn start(args: Args) -> Result<()> { let addr = SocketAddr::new(args.listen, args.port); let mut sigterm = signal(SignalKind::terminate())?; let mut sigint = signal(SignalKind::interrupt())?; let mut options = ServerOptions::default(); options.secret = args.secret; options.override_origin = args.override_origin; options.redis_url = args.redis_url; options.host = args.host; let server = Server::new(options)?; let serve_task = async { info!("server listening at {addr}"); server.bind(&addr).await }; let signals_task = async { tokio::select! { Some(()) = sigterm.recv() => (), Some(()) = sigint.recv() => (), else => return Ok(()), } info!("gracefully shutting down..."); server.shutdown(); Ok(()) }; tokio::try_join!(serve_task, signals_task)?; Ok(()) } fn main() -> ExitCode { let args = Args::parse(); tracing_subscriber::fmt() .with_env_filter(std::env::var("RUST_LOG").unwrap_or("info".into())) .with_writer(std::io::stderr) .init(); match start(args) { Ok(()) => ExitCode::SUCCESS, Err(err) => { error!("{err:?}"); ExitCode::FAILURE } } } ================================================ FILE: crates/sshx-server/src/session/snapshot.rs ================================================ //! Snapshot and restore sessions from serialized state. use std::collections::BTreeMap; use anyhow::{ensure, Context, Result}; use prost::Message; use sshx_core::{ proto::{SerializedSession, SerializedShell}, Sid, Uid, }; use super::{Metadata, Session, State}; use crate::web::protocol::WsWinsize; /// Persist at most this many bytes of output in storage, per shell. const SHELL_SNAPSHOT_BYTES: u64 = 1 << 15; // 32 KiB const MAX_SNAPSHOT_SIZE: usize = 1 << 22; // 4 MiB impl Session { /// Snapshot the session, returning a compressed representation. pub fn snapshot(&self) -> Result> { let ids = self.counter.get_current_values(); let winsizes: BTreeMap = self.source.borrow().iter().cloned().collect(); let message = SerializedSession { encrypted_zeros: self.metadata().encrypted_zeros.clone(), shells: self .shells .read() .iter() .map(|(sid, shell)| { // Prune off data until its total length is at most `SHELL_SNAPSHOT_BYTES`. let mut prefix = 0; let mut chunk_offset = shell.chunk_offset; let mut byte_offset = shell.byte_offset; for i in 0..shell.data.len() { if shell.seqnum - byte_offset > SHELL_SNAPSHOT_BYTES { prefix += 1; chunk_offset += 1; byte_offset += shell.data[i].len() as u64; } else { break; } } let winsize = winsizes.get(sid).cloned().unwrap_or_default(); let shell = SerializedShell { seqnum: shell.seqnum, data: shell.data[prefix..].to_vec(), chunk_offset, byte_offset, closed: shell.closed, winsize_x: winsize.x, winsize_y: winsize.y, winsize_rows: winsize.rows.into(), winsize_cols: winsize.cols.into(), }; (sid.0, shell) }) .collect(), next_sid: ids.0 .0, next_uid: ids.1 .0, name: self.metadata().name.clone(), write_password_hash: self.metadata().write_password_hash.clone(), }; let data = message.encode_to_vec(); ensure!(data.len() < MAX_SNAPSHOT_SIZE, "snapshot too large"); Ok(zstd::bulk::compress(&data, 3)?) } /// Restore the session from a previous compressed snapshot. pub fn restore(data: &[u8]) -> Result { let data = zstd::bulk::decompress(data, MAX_SNAPSHOT_SIZE)?; let message = SerializedSession::decode(&*data)?; let metadata = Metadata { encrypted_zeros: message.encrypted_zeros, name: message.name, write_password_hash: message.write_password_hash, }; let session = Self::new(metadata); let mut shells = session.shells.write(); let mut winsizes = Vec::new(); for (sid, shell) in message.shells { winsizes.push(( Sid(sid), WsWinsize { x: shell.winsize_x, y: shell.winsize_y, rows: shell.winsize_rows.try_into().context("rows overflow")?, cols: shell.winsize_cols.try_into().context("cols overflow")?, }, )); let shell = State { seqnum: shell.seqnum, data: shell.data, chunk_offset: shell.chunk_offset, byte_offset: shell.byte_offset, closed: shell.closed, notify: Default::default(), }; shells.insert(Sid(sid), shell); } drop(shells); session.source.send_replace(winsizes); session .counter .set_current_values(Sid(message.next_sid), Uid(message.next_uid)); Ok(session) } } ================================================ FILE: crates/sshx-server/src/session.rs ================================================ //! Core logic for sshx sessions, independent of message transport. use std::collections::HashMap; use std::ops::DerefMut; use std::sync::Arc; use anyhow::{bail, Context, Result}; use bytes::Bytes; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; use sshx_core::{ proto::{server_update::ServerMessage, SequenceNumbers}, IdCounter, Sid, Uid, }; use tokio::sync::{broadcast, watch, Notify}; use tokio::time::Instant; use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream, WatchStream}; use tokio_stream::Stream; use tracing::{debug, warn}; use crate::utils::Shutdown; use crate::web::protocol::{WsServer, WsUser, WsWinsize}; mod snapshot; /// Store a rolling buffer with at most this quantity of output, per shell. const SHELL_STORED_BYTES: u64 = 1 << 21; // 2 MiB /// Static metadata for this session. #[derive(Debug, Clone)] pub struct Metadata { /// Used to validate that clients have the correct encryption key. pub encrypted_zeros: Bytes, /// Name of the session (human-readable). pub name: String, /// Password for write access to the session. pub write_password_hash: Option, } /// In-memory state for a single sshx session. #[derive(Debug)] pub struct Session { /// Static metadata for this session. metadata: Metadata, /// In-memory state for the session. shells: RwLock>, /// Metadata for currently connected users. users: RwLock>, /// Atomic counter to get new, unique IDs. counter: IdCounter, /// Timestamp of the last backend client message from an active connection. last_accessed: Mutex, /// Watch channel source for the ordered list of open shells and sizes. source: watch::Sender>, /// Broadcasts updates to all WebSocket clients. /// /// Every update inside this channel must be of idempotent form, since /// messages may arrive before or after any snapshot of the current session /// state. Duplicated events should remain consistent. broadcast: broadcast::Sender, /// Sender end of a channel that buffers messages for the client. update_tx: async_channel::Sender, /// Receiver end of a channel that buffers messages for the client. update_rx: async_channel::Receiver, /// Triggered from metadata events when an immediate snapshot is needed. sync_notify: Notify, /// Set when this session has been closed and removed. shutdown: Shutdown, } /// Internal state for each shell. #[derive(Default, Debug)] struct State { /// Sequence number, indicating how many bytes have been received. seqnum: u64, /// Terminal data chunks. data: Vec, /// Number of pruned data chunks before `data[0]`. chunk_offset: u64, /// Number of bytes in pruned data chunks. byte_offset: u64, /// Set when this shell is terminated. closed: bool, /// Updated when any of the above fields change. notify: Arc, } impl Session { /// Construct a new session. pub fn new(metadata: Metadata) -> Self { let now = Instant::now(); let (update_tx, update_rx) = async_channel::bounded(256); Session { metadata, shells: RwLock::new(HashMap::new()), users: RwLock::new(HashMap::new()), counter: IdCounter::default(), last_accessed: Mutex::new(now), source: watch::channel(Vec::new()).0, broadcast: broadcast::channel(64).0, update_tx, update_rx, sync_notify: Notify::new(), shutdown: Shutdown::new(), } } /// Returns the metadata for this session. pub fn metadata(&self) -> &Metadata { &self.metadata } /// Gives access to the ID counter for obtaining new IDs. pub fn counter(&self) -> &IdCounter { &self.counter } /// Return the sequence numbers for current shells. pub fn sequence_numbers(&self) -> SequenceNumbers { let shells = self.shells.read(); let mut map = HashMap::with_capacity(shells.len()); for (key, value) in &*shells { if !value.closed { map.insert(key.0, value.seqnum); } } SequenceNumbers { map } } /// Receive a notification on broadcasted message events. pub fn subscribe_broadcast( &self, ) -> impl Stream> + Unpin { BroadcastStream::new(self.broadcast.subscribe()) } /// Receive a notification every time the set of shells is changed. pub fn subscribe_shells(&self) -> impl Stream> + Unpin { WatchStream::new(self.source.subscribe()) } /// Subscribe for chunks from a shell, until it is closed. pub fn subscribe_chunks( &self, id: Sid, mut chunknum: u64, ) -> impl Stream)> + '_ { async_stream::stream! { while !self.shutdown.is_terminated() { // We absolutely cannot hold `shells` across an await point, // since that would cause deadlocks. let (seqnum, chunks, notified) = { let shells = self.shells.read(); let shell = match shells.get(&id) { Some(shell) if !shell.closed => shell, _ => return, }; let notify = Arc::clone(&shell.notify); let notified = async move { notify.notified().await }; let mut seqnum = shell.byte_offset; let mut chunks = Vec::new(); let current_chunks = shell.chunk_offset + shell.data.len() as u64; if chunknum < current_chunks { let start = chunknum.saturating_sub(shell.chunk_offset) as usize; seqnum += shell.data[..start].iter().map(|x| x.len() as u64).sum::(); chunks = shell.data[start..].to_vec(); chunknum = current_chunks; } (seqnum, chunks, notified) }; if !chunks.is_empty() { yield (seqnum, chunks); } tokio::select! { _ = notified => (), _ = self.terminated() => return, } } } } /// Add a new shell to the session. pub fn add_shell(&self, id: Sid, center: (i32, i32)) -> Result<()> { use std::collections::hash_map::Entry::*; let _guard = match self.shells.write().entry(id) { Occupied(_) => bail!("shell already exists with id={id}"), Vacant(v) => v.insert(State::default()), }; self.source.send_modify(|source| { let winsize = WsWinsize { x: center.0, y: center.1, ..Default::default() }; source.push((id, winsize)); }); self.sync_now(); Ok(()) } /// Terminates an existing shell. pub fn close_shell(&self, id: Sid) -> Result<()> { match self.shells.write().get_mut(&id) { Some(shell) if !shell.closed => { shell.closed = true; shell.notify.notify_waiters(); } Some(_) => return Ok(()), None => bail!("cannot close shell with id={id}, does not exist"), } self.source.send_modify(|source| { source.retain(|&(x, _)| x != id); }); self.sync_now(); Ok(()) } fn get_shell_mut(&self, id: Sid) -> Result + '_> { let shells = self.shells.write(); match shells.get(&id) { Some(shell) if !shell.closed => { Ok(RwLockWriteGuard::map(shells, |s| s.get_mut(&id).unwrap())) } Some(_) => bail!("cannot update shell with id={id}, already closed"), None => bail!("cannot update shell with id={id}, does not exist"), } } /// Change the size of a terminal, notifying clients if necessary. pub fn move_shell(&self, id: Sid, winsize: Option) -> Result<()> { let _guard = self.get_shell_mut(id)?; // Ensures mutual exclusion. self.source.send_modify(|source| { if let Some(idx) = source.iter().position(|&(sid, _)| sid == id) { let (_, oldsize) = source.remove(idx); source.push((id, winsize.unwrap_or(oldsize))); } }); Ok(()) } /// Receive new data into the session. pub fn add_data(&self, id: Sid, data: Bytes, seq: u64) -> Result<()> { let mut shell = self.get_shell_mut(id)?; if seq <= shell.seqnum && seq + data.len() as u64 > shell.seqnum { let start = shell.seqnum - seq; let segment = data.slice(start as usize..); debug!(%id, bytes = segment.len(), "adding data to shell"); shell.seqnum += segment.len() as u64; shell.data.push(segment); // Prune old chunks if we've exceeded the maximum stored bytes. let mut stored_bytes = shell.seqnum - shell.byte_offset; if stored_bytes > SHELL_STORED_BYTES { let mut offset = 0; while offset < shell.data.len() && stored_bytes > SHELL_STORED_BYTES { let bytes = shell.data[offset].len() as u64; stored_bytes -= bytes; shell.chunk_offset += 1; shell.byte_offset += bytes; offset += 1; } shell.data.drain(..offset); } shell.notify.notify_waiters(); } Ok(()) } /// List all the users in the session. pub fn list_users(&self) -> Vec<(Uid, WsUser)> { self.users .read() .iter() .map(|(k, v)| (*k, v.clone())) .collect() } /// Update a user in place by ID, applying a callback to the object. pub fn update_user(&self, id: Uid, f: impl FnOnce(&mut WsUser)) -> Result<()> { let updated_user = { let mut users = self.users.write(); let user = users.get_mut(&id).context("user not found")?; f(user); user.clone() }; self.broadcast .send(WsServer::UserDiff(id, Some(updated_user))) .ok(); Ok(()) } /// Add a new user, and return a guard that removes the user when dropped. pub fn user_scope(&self, id: Uid, can_write: bool) -> Result { use std::collections::hash_map::Entry::*; #[must_use] struct UserGuard<'a>(&'a Session, Uid); impl Drop for UserGuard<'_> { fn drop(&mut self) { self.0.remove_user(self.1); } } match self.users.write().entry(id) { Occupied(_) => bail!("user already exists with id={id}"), Vacant(v) => { let user = WsUser { name: format!("User {id}"), cursor: None, focus: None, can_write, }; v.insert(user.clone()); self.broadcast.send(WsServer::UserDiff(id, Some(user))).ok(); Ok(UserGuard(self, id)) } } } /// Remove an existing user. fn remove_user(&self, id: Uid) { if self.users.write().remove(&id).is_none() { warn!(%id, "invariant violation: removed user that does not exist"); } self.broadcast.send(WsServer::UserDiff(id, None)).ok(); } /// Check if a user has write permission in the session. pub fn check_write_permission(&self, user_id: Uid) -> Result<()> { let users = self.users.read(); let user = users.get(&user_id).context("user not found")?; if !user.can_write { bail!("No write permission"); } Ok(()) } /// Send a chat message into the room. pub fn send_chat(&self, id: Uid, msg: &str) -> Result<()> { // Populate the message with the current name in case it's not known later. let name = { let users = self.users.read(); users.get(&id).context("user not found")?.name.clone() }; self.broadcast .send(WsServer::Hear(id, name, msg.into())) .ok(); Ok(()) } /// Send a measurement of the shell latency. pub fn send_latency_measurement(&self, latency: u64) { self.broadcast.send(WsServer::ShellLatency(latency)).ok(); } /// Register a backend client heartbeat, refreshing the timestamp. pub fn access(&self) { *self.last_accessed.lock() = Instant::now(); } /// Returns the timestamp of the last backend client activity. pub fn last_accessed(&self) -> Instant { *self.last_accessed.lock() } /// Access the sender of the client message channel for this session. pub fn update_tx(&self) -> &async_channel::Sender { &self.update_tx } /// Access the receiver of the client message channel for this session. pub fn update_rx(&self) -> &async_channel::Receiver { &self.update_rx } /// Mark the session as requiring an immediate storage sync. /// /// This is needed for consistency when creating new shells, removing old /// shells, or updating the ID counter. If these operations are lost in a /// server restart, then the snapshot that contains them would be invalid /// compared to the current backend client state. /// /// Note that it is not necessary to do this all the time though, since that /// would put too much pressure on the database. Lost terminal data is /// already re-synchronized periodically. pub fn sync_now(&self) { self.sync_notify.notify_one(); } /// Resolves when the session has been marked for an immediate sync. pub async fn sync_now_wait(&self) { self.sync_notify.notified().await } /// Send a termination signal to exit this session. pub fn shutdown(&self) { self.shutdown.shutdown() } /// Resolves when the session has received a shutdown signal. pub async fn terminated(&self) { self.shutdown.wait().await } } ================================================ FILE: crates/sshx-server/src/state/mesh.rs ================================================ //! Storage and distributed communication. use std::{pin::pin, sync::Arc, time::Duration}; use anyhow::Result; use redis::AsyncCommands; use tokio::time; use tokio_stream::{Stream, StreamExt}; use tracing::error; use crate::session::Session; /// Interval for syncing the latest session state into persistent storage. const STORAGE_SYNC_INTERVAL: Duration = Duration::from_secs(20); /// Length of time a key lasts in Redis before it is expired. const STORAGE_EXPIRY: Duration = Duration::from_secs(300); fn set_opts() -> redis::SetOptions { redis::SetOptions::default() .with_expiration(redis::SetExpiry::PX(STORAGE_EXPIRY.as_millis() as u64)) } /// Communication with a distributed mesh of sshx server nodes. /// /// This uses a Redis instance to persist data across restarts, as well as a /// pub/sub channel to keep be notified of when another node becomes the owner /// of an active session. /// /// All servers must be accessible to each other through TCP mesh networking, /// since requests are forwarded to the controller of a given session. #[derive(Clone)] pub struct StorageMesh { redis: deadpool_redis::Pool, redis_pubsub: redis::Client, host: Option, } impl StorageMesh { /// Construct a new storage object from Redis URL. pub fn new(redis_url: &str, host: Option<&str>) -> Result { let redis = deadpool_redis::Config::from_url(redis_url) .builder()? .max_size(10) .wait_timeout(Some(Duration::from_secs(5))) .runtime(deadpool_redis::Runtime::Tokio1) .build()?; // Separate `redis::Client` just for pub/sub connections. // // At time of writing, deadpool-redis has not been updated to support the new // pub/sub client APIs in Rust. This is a temporary workaround that creates a // new Redis client on the side, bypassing the connection pool. // // Reference: https://github.com/deadpool-rs/deadpool/issues/226 let redis_pubsub = redis::Client::open(redis_url)?; Ok(Self { redis, redis_pubsub, host: host.map(|s| s.to_string()), }) } /// Returns the hostname of this server, if running in mesh node. pub fn host(&self) -> Option<&str> { self.host.as_deref() } /// Retrieve the hostname of the owner of a session. pub async fn get_owner(&self, name: &str) -> Result> { let mut conn = self.redis.get().await?; let (owner, closed) = redis::pipe() .get(format!("session:{{{name}}}:owner")) .get(format!("session:{{{name}}}:closed")) .query_async(&mut conn) .await?; if closed { Ok(None) } else { Ok(owner) } } /// Retrieve the owner and snapshot of a session. pub async fn get_owner_snapshot( &self, name: &str, ) -> Result<(Option, Option>)> { let mut conn = self.redis.get().await?; let (owner, snapshot, closed) = redis::pipe() .get(format!("session:{{{name}}}:owner")) .get(format!("session:{{{name}}}:snapshot")) .get(format!("session:{{{name}}}:closed")) .query_async(&mut conn) .await?; if closed { Ok((None, None)) } else { Ok((owner, snapshot)) } } /// Periodically set the owner and snapshot of a session. pub async fn background_sync(&self, name: &str, session: Arc) { let mut interval = time::interval(STORAGE_SYNC_INTERVAL); interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay); loop { tokio::select! { _ = interval.tick() => {} _ = session.sync_now_wait() => {} _ = session.terminated() => break, } let mut conn = match self.redis.get().await { Ok(conn) => conn, Err(err) => { error!(?err, "failed to connect to redis for sync"); continue; } }; let snapshot = match session.snapshot() { Ok(snapshot) => snapshot, Err(err) => { error!(?err, "failed to snapshot session {name}"); continue; } }; let mut pipe = redis::pipe(); if let Some(host) = &self.host { pipe.set_options(format!("session:{{{name}}}:owner"), host, set_opts()); } pipe.set_options(format!("session:{{{name}}}:snapshot"), snapshot, set_opts()); match pipe.query_async(&mut conn).await { Ok(()) => {} Err(err) => error!(?err, "failed to sync session {name}"), } } } /// Mark a session as closed, so it will expire and never be accessed again. pub async fn mark_closed(&self, name: &str) -> Result<()> { let mut conn = self.redis.get().await?; let (owner,): (Option,) = redis::pipe() .get_del(format!("session:{{{name}}}:owner")) .del(format!("session:{{{name}}}:snapshot")) .ignore() .set_options(format!("session:{{{name}}}:closed"), true, set_opts()) .ignore() .query_async(&mut conn) .await?; if let Some(owner) = owner { self.notify_transfer(name, &owner).await?; } Ok(()) } /// Notify a host that a session has been transferred. pub async fn notify_transfer(&self, name: &str, host: &str) -> Result<()> { let mut conn = self.redis.get().await?; () = conn.publish(format!("transfers:{host}"), name).await?; Ok(()) } /// Listen for sessions that are transferred away from this host. pub fn listen_for_transfers(&self) -> impl Stream + Send + '_ { async_stream::stream! { let Some(host) = &self.host else { // If not in a mesh, there are no transfers. return; }; loop { // Requires an owned, non-pool connection for ownership reasons. let mut pubsub = match self.redis_pubsub.get_async_pubsub().await { Ok(pubsub) => pubsub, Err(err) => { error!(?err, "failed to connect to redis for pub/sub"); time::sleep(Duration::from_secs(5)).await; continue; } }; if let Err(err) = pubsub.subscribe(format!("transfers:{host}")).await { error!(?err, "failed to subscribe to transfers"); time::sleep(Duration::from_secs(1)).await; continue; } let mut msg_stream = pin!(pubsub.into_on_message()); while let Some(msg) = msg_stream.next().await { match msg.get_payload::() { Ok(payload) => yield payload, Err(err) => { error!(?err, "failed to parse transfers message"); continue; } }; } } } } } ================================================ FILE: crates/sshx-server/src/state.rs ================================================ //! Stateful components of the server, managing multiple sessions. use std::pin::pin; use std::sync::Arc; use std::time::Duration; use anyhow::Result; use dashmap::DashMap; use hmac::{Hmac, Mac as _}; use sha2::Sha256; use sshx_core::rand_alphanumeric; use tokio::time; use tokio_stream::StreamExt; use tracing::error; use self::mesh::StorageMesh; use crate::session::Session; use crate::ServerOptions; pub mod mesh; /// Timeout for a disconnected session to be evicted and closed. /// /// If a session has no backend clients making connections in this interval, /// then its updated timestamp will be out-of-date, so we close it and remove it /// from the state to reduce memory usage. const DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300); /// Shared state object for global server logic. pub struct ServerState { /// Message authentication code for signing tokens. mac: Hmac, /// Override the origin returned for the Open() RPC. override_origin: Option, /// A concurrent map of session IDs to session objects. store: DashMap>, /// Storage and distributed communication provider, if enabled. mesh: Option, } impl ServerState { /// Create an empty server state using the given secret. pub fn new(options: ServerOptions) -> Result { let secret = options.secret.unwrap_or_else(|| rand_alphanumeric(22)); let mesh = match options.redis_url { Some(url) => Some(StorageMesh::new(&url, options.host.as_deref())?), None => None, }; Ok(Self { mac: Hmac::new_from_slice(secret.as_bytes()).unwrap(), override_origin: options.override_origin, store: DashMap::new(), mesh, }) } /// Returns the message authentication code used for signing tokens. pub fn mac(&self) -> Hmac { self.mac.clone() } /// Returns the override origin for the Open() RPC. pub fn override_origin(&self) -> Option { self.override_origin.clone() } /// Lookup a local session by name. pub fn lookup(&self, name: &str) -> Option> { self.store.get(name).map(|s| s.clone()) } /// Insert a session into the local store. pub fn insert(&self, name: &str, session: Arc) { if let Some(mesh) = &self.mesh { let name = name.to_string(); let session = session.clone(); let mesh = mesh.clone(); tokio::spawn(async move { mesh.background_sync(&name, session).await; }); } if let Some(prev_session) = self.store.insert(name.to_string(), session) { prev_session.shutdown(); } } /// Remove a session from the local store. pub fn remove(&self, name: &str) -> bool { if let Some((_, session)) = self.store.remove(name) { session.shutdown(); true } else { false } } /// Close a session permanently on this and other servers. pub async fn close_session(&self, name: &str) -> Result<()> { self.remove(name); if let Some(mesh) = &self.mesh { mesh.mark_closed(name).await?; } Ok(()) } /// Connect to a session by name from the `sshx` client, which provides the /// actual terminal backend. pub async fn backend_connect(&self, name: &str) -> Result>> { if let Some(session) = self.lookup(name) { return Ok(Some(session)); } if let Some(mesh) = &self.mesh { let (owner, snapshot) = mesh.get_owner_snapshot(name).await?; if let Some(snapshot) = snapshot { let session = Arc::new(Session::restore(&snapshot)?); self.insert(name, session.clone()); if let Some(owner) = owner { mesh.notify_transfer(name, &owner).await?; } return Ok(Some(session)); } } Ok(None) } /// Connect to a session from a web browser frontend, possibly redirecting. pub async fn frontend_connect( &self, name: &str, ) -> Result, Option>> { if let Some(session) = self.lookup(name) { return Ok(Ok(session)); } if let Some(mesh) = &self.mesh { let mut owner = mesh.get_owner(name).await?; if owner.is_some() && owner.as_deref() == mesh.host() { // Do not redirect back to the same server. owner = None; } return Ok(Err(owner)); } Ok(Err(None)) } /// Listen for and remove sessions that are transferred away from this host. pub async fn listen_for_transfers(&self) { if let Some(mesh) = &self.mesh { let mut transfers = pin!(mesh.listen_for_transfers()); while let Some(name) = transfers.next().await { self.remove(&name); } } } /// Close all sessions that have been disconnected for too long. pub async fn close_old_sessions(&self) { loop { time::sleep(DISCONNECTED_SESSION_EXPIRY / 5).await; let mut to_close = Vec::new(); for entry in &self.store { let session = entry.value(); if session.last_accessed().elapsed() > DISCONNECTED_SESSION_EXPIRY { to_close.push(entry.key().clone()); } } for name in to_close { if let Err(err) = self.close_session(&name).await { error!(?err, "failed to close old session {name}"); } } } } /// Send a graceful shutdown signal to every session. pub fn shutdown(&self) { for entry in &self.store { entry.value().shutdown(); } } } ================================================ FILE: crates/sshx-server/src/utils.rs ================================================ //! Utility functions shared among server logic. use std::fmt::Debug; use std::future::Future; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::Notify; /// A cloneable structure that handles shutdown signals. #[derive(Clone)] pub struct Shutdown { inner: Arc<(AtomicBool, Notify)>, } impl Shutdown { /// Construct a new [`Shutdown`] object. pub fn new() -> Self { Self { inner: Arc::new((AtomicBool::new(false), Notify::new())), } } /// Send a shutdown signal to all listeners. pub fn shutdown(&self) { self.inner.0.swap(true, Ordering::Relaxed); self.inner.1.notify_waiters(); } /// Returns whether the shutdown signal has been previously sent. pub fn is_terminated(&self) -> bool { self.inner.0.load(Ordering::Relaxed) } /// Wait for the shutdown signal, if it has not already been sent. pub fn wait(&'_ self) -> impl Future + Send { let inner = self.inner.clone(); async move { // Initial fast check if !inner.0.load(Ordering::Relaxed) { let notify = inner.1.notified(); // Second check to avoid "missed wakeup" race conditions if !inner.0.load(Ordering::Relaxed) { notify.await; } } } } } impl Default for Shutdown { fn default() -> Self { Self::new() } } impl Debug for Shutdown { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Shutdown") .field("is_terminated", &self.inner.0.load(Ordering::Relaxed)) .finish() } } ================================================ FILE: crates/sshx-server/src/web/protocol.rs ================================================ //! Serializable types sent and received by the web server. use bytes::Bytes; use serde::{Deserialize, Serialize}; use sshx_core::{Sid, Uid}; /// Real-time message conveying the position and size of a terminal. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WsWinsize { /// The top-left x-coordinate of the window, offset from origin. pub x: i32, /// The top-left y-coordinate of the window, offset from origin. pub y: i32, /// The number of rows in the window. pub rows: u16, /// The number of columns in the terminal. pub cols: u16, } impl Default for WsWinsize { fn default() -> Self { WsWinsize { x: 0, y: 0, rows: 24, cols: 80, } } } /// Real-time message providing information about a user. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WsUser { /// The user's display name. pub name: String, /// Live coordinates of the mouse cursor, if available. pub cursor: Option<(i32, i32)>, /// Currently focused terminal window ID. pub focus: Option, /// Whether the user has write permissions in the session. pub can_write: bool, } /// A real-time message sent from the server over WebSocket. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum WsServer { /// Initial server message, with the user's ID and session metadata. Hello(Uid, String), /// The user's authentication was invalid. InvalidAuth(), /// A snapshot of all current users in the session. Users(Vec<(Uid, WsUser)>), /// Info about a single user in the session: joined, left, or changed. UserDiff(Uid, Option), /// Notification when the set of open shells has changed. Shells(Vec<(Sid, WsWinsize)>), /// Subscription results, in the form of terminal data chunks. Chunks(Sid, u64, Vec), /// Get a chat message tuple `(uid, name, text)` from the room. Hear(Uid, String, String), /// Forward a latency measurement between the server and backend shell. ShellLatency(u64), /// Echo back a timestamp, for the the client's own latency measurement. Pong(u64), /// Alert the client of an application error. Error(String), } /// A real-time message sent from the client over WebSocket. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum WsClient { /// Authenticate the user's encryption key by zeros block and write password /// (if provided). Authenticate(Bytes, Option), /// Set the name of the current user. SetName(String), /// Send real-time information about the user's cursor. SetCursor(Option<(i32, i32)>), /// Set the currently focused shell. SetFocus(Option), /// Create a new shell. Create(i32, i32), /// Close a specific shell. Close(Sid), /// Move a shell window to a new position and focus it. Move(Sid, Option), /// Add user data to a given shell. Data(Sid, Bytes, u64), /// Subscribe to a shell, starting at a given chunk index. Subscribe(Sid, u64), /// Send a a chat message to the room. Chat(String), /// Send a ping to the server, for latency measurement. Ping(u64), } ================================================ FILE: crates/sshx-server/src/web/socket.rs ================================================ use std::collections::HashSet; use std::sync::Arc; use anyhow::{Context, Result}; use axum::extract::{ ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, Path, State, }; use axum::response::IntoResponse; use bytes::Bytes; use futures_util::SinkExt; use sshx_core::proto::{server_update::ServerMessage, NewShell, TerminalInput, TerminalSize}; use sshx_core::Sid; use subtle::ConstantTimeEq; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tracing::{error, info_span, warn, Instrument}; use crate::session::Session; use crate::web::protocol::{WsClient, WsServer}; use crate::ServerState; pub async fn get_session_ws( Path(name): Path, ws: WebSocketUpgrade, State(state): State>, ) -> impl IntoResponse { ws.on_upgrade(move |mut socket| { let span = info_span!("ws", %name); async move { match state.frontend_connect(&name).await { Ok(Ok(session)) => { if let Err(err) = handle_socket(&mut socket, session).await { warn!(?err, "websocket exiting early"); } else { socket.close().await.ok(); } } Ok(Err(Some(host))) => { if let Err(err) = proxy_redirect(&mut socket, &host, &name).await { error!(?err, "failed to proxy websocket"); let frame = CloseFrame { code: 4500, reason: format!("proxy redirect: {err}").into(), }; socket.send(Message::Close(Some(frame))).await.ok(); } else { socket.close().await.ok(); } } Ok(Err(None)) => { let frame = CloseFrame { code: 4404, reason: "could not find the requested session".into(), }; socket.send(Message::Close(Some(frame))).await.ok(); } Err(err) => { error!(?err, "failed to connect to frontend session"); let frame = CloseFrame { code: 4500, reason: format!("session connect: {err}").into(), }; socket.send(Message::Close(Some(frame))).await.ok(); } } } .instrument(span) }) } /// Handle an incoming live WebSocket connection to a given session. async fn handle_socket(socket: &mut WebSocket, session: Arc) -> Result<()> { /// Send a message to the client over WebSocket. async fn send(socket: &mut WebSocket, msg: WsServer) -> Result<()> { let mut buf = Vec::new(); ciborium::ser::into_writer(&msg, &mut buf)?; socket.send(Message::Binary(Bytes::from(buf))).await?; Ok(()) } /// Receive a message from the client over WebSocket. async fn recv(socket: &mut WebSocket) -> Result> { Ok(loop { match socket.recv().await.transpose()? { Some(Message::Text(_)) => warn!("ignoring text message over WebSocket"), Some(Message::Binary(msg)) => break Some(ciborium::de::from_reader(&*msg)?), Some(_) => (), // ignore other message types, keep looping None => break None, } }) } let metadata = session.metadata(); let user_id = session.counter().next_uid(); session.sync_now(); send(socket, WsServer::Hello(user_id, metadata.name.clone())).await?; let can_write = match recv(socket).await? { Some(WsClient::Authenticate(bytes, write_password_bytes)) => { // Constant-time comparison of bytes, converting Choice to bool if !bool::from(bytes.ct_eq(metadata.encrypted_zeros.as_ref())) { send(socket, WsServer::InvalidAuth()).await?; return Ok(()); } match (write_password_bytes, &metadata.write_password_hash) { // No password needed, so all users can write (default). (_, None) => true, // Password stored but not provided, user is read-only. (None, Some(_)) => false, // Password stored and provided, compare them. (Some(provided), Some(stored)) => { if !bool::from(provided.ct_eq(stored)) { send(socket, WsServer::InvalidAuth()).await?; return Ok(()); } true } } } _ => { send(socket, WsServer::InvalidAuth()).await?; return Ok(()); } }; let _user_guard = session.user_scope(user_id, can_write)?; let update_tx = session.update_tx(); // start listening for updates before any state reads let mut broadcast_stream = session.subscribe_broadcast(); send(socket, WsServer::Users(session.list_users())).await?; let mut subscribed = HashSet::new(); // prevent duplicate subscriptions let (chunks_tx, mut chunks_rx) = mpsc::channel::<(Sid, u64, Vec)>(1); let mut shells_stream = session.subscribe_shells(); loop { let msg = tokio::select! { _ = session.terminated() => break, Some(result) = broadcast_stream.next() => { let msg = result.context("client fell behind on broadcast stream")?; send(socket, msg).await?; continue; } Some(shells) = shells_stream.next() => { send(socket, WsServer::Shells(shells)).await?; continue; } Some((id, seqnum, chunks)) = chunks_rx.recv() => { send(socket, WsServer::Chunks(id, seqnum, chunks)).await?; continue; } result = recv(socket) => { match result? { Some(msg) => msg, None => break, } } }; match msg { WsClient::Authenticate(_, _) => {} WsClient::SetName(name) => { if !name.is_empty() { session.update_user(user_id, |user| user.name = name)?; } } WsClient::SetCursor(cursor) => { session.update_user(user_id, |user| user.cursor = cursor)?; } WsClient::SetFocus(id) => { session.update_user(user_id, |user| user.focus = id)?; } WsClient::Create(x, y) => { if let Err(e) = session.check_write_permission(user_id) { send(socket, WsServer::Error(e.to_string())).await?; continue; } let id = session.counter().next_sid(); session.sync_now(); let new_shell = NewShell { id: id.0, x, y }; update_tx .send(ServerMessage::CreateShell(new_shell)) .await?; } WsClient::Close(id) => { if let Err(e) = session.check_write_permission(user_id) { send(socket, WsServer::Error(e.to_string())).await?; continue; } update_tx.send(ServerMessage::CloseShell(id.0)).await?; } WsClient::Move(id, winsize) => { if let Err(e) = session.check_write_permission(user_id) { send(socket, WsServer::Error(e.to_string())).await?; continue; } if let Err(err) = session.move_shell(id, winsize) { send(socket, WsServer::Error(err.to_string())).await?; continue; } if let Some(winsize) = winsize { let msg = ServerMessage::Resize(TerminalSize { id: id.0, rows: winsize.rows as u32, cols: winsize.cols as u32, }); session.update_tx().send(msg).await?; } } WsClient::Data(id, data, offset) => { if let Err(e) = session.check_write_permission(user_id) { send(socket, WsServer::Error(e.to_string())).await?; continue; } let input = TerminalInput { id: id.0, data, offset, }; update_tx.send(ServerMessage::Input(input)).await?; } WsClient::Subscribe(id, chunknum) => { if subscribed.contains(&id) { continue; } subscribed.insert(id); let session = Arc::clone(&session); let chunks_tx = chunks_tx.clone(); tokio::spawn(async move { let stream = session.subscribe_chunks(id, chunknum); tokio::pin!(stream); while let Some((seqnum, chunks)) = stream.next().await { if chunks_tx.send((id, seqnum, chunks)).await.is_err() { break; } } }); } WsClient::Chat(msg) => { session.send_chat(user_id, &msg)?; } WsClient::Ping(ts) => { send(socket, WsServer::Pong(ts)).await?; } } } Ok(()) } /// Transparently reverse-proxy a WebSocket connection to a different host. async fn proxy_redirect(socket: &mut WebSocket, host: &str, name: &str) -> Result<()> { use tokio_tungstenite::{ connect_async, tungstenite::protocol::{CloseFrame as TCloseFrame, Message as TMessage}, }; let (mut upstream, _) = connect_async(format!("ws://{host}/api/s/{name}")).await?; loop { // Due to axum having its own WebSocket API types, we need to manually translate // between it and tungstenite's message type. tokio::select! { Some(client_msg) = socket.recv() => { let msg = match client_msg { Ok(Message::Text(s)) => Some(TMessage::Text(s.as_str().into())), Ok(Message::Binary(b)) => Some(TMessage::Binary(b)), Ok(Message::Close(frame)) => { let frame = frame.map(|frame| TCloseFrame { code: frame.code.into(), reason: frame.reason.as_str().into(), }); Some(TMessage::Close(frame)) } Ok(_) => None, Err(_) => break, }; if let Some(msg) = msg { if upstream.send(msg).await.is_err() { break; } } } Some(server_msg) = upstream.next() => { let msg = match server_msg { Ok(TMessage::Text(s)) => Some(Message::Text(s.as_str().into())), Ok(TMessage::Binary(b)) => Some(Message::Binary(b)), Ok(TMessage::Close(frame)) => { let frame = frame.map(|frame| CloseFrame { code: frame.code.into(), reason: frame.reason.as_str().into(), }); Some(Message::Close(frame)) } Ok(_) => None, Err(_) => break, }; if let Some(msg) = msg { if socket.send(msg).await.is_err() { break; } } } else => break, } } Ok(()) } ================================================ FILE: crates/sshx-server/src/web.rs ================================================ //! HTTP and WebSocket handlers for the sshx web interface. use std::sync::Arc; use axum::routing::{any, get_service}; use axum::Router; use tower_http::services::{ServeDir, ServeFile}; use crate::ServerState; pub mod protocol; mod socket; /// Returns the web application server, routed with Axum. pub fn app() -> Router> { let root_spa = ServeFile::new("build/spa.html") .precompressed_gzip() .precompressed_br(); // Serves static SvelteKit build files. let static_files = ServeDir::new("build") .precompressed_gzip() .precompressed_br() .fallback(root_spa); Router::new() .nest("/api", backend()) .fallback_service(get_service(static_files)) } /// Routes for the backend web API server. fn backend() -> Router> { Router::new().route("/s/{name}", any(socket::get_session_ws)) } ================================================ FILE: crates/sshx-server/tests/common/mod.rs ================================================ use std::collections::{BTreeMap, HashMap}; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use anyhow::{ensure, Result}; use axum::serve::ListenerExt; use futures_util::{SinkExt, StreamExt}; use http::StatusCode; use sshx::encrypt::Encrypt; use sshx_core::proto::sshx_service_client::SshxServiceClient; use sshx_core::{Sid, Uid}; use sshx_server::{ state::ServerState, web::protocol::{WsClient, WsServer, WsUser, WsWinsize}, Server, }; use tokio::net::{TcpListener, TcpStream}; use tokio::time; use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tonic::transport::Channel; /// An ephemeral, isolated server that is created for each test. pub struct TestServer { local_addr: SocketAddr, server: Arc, } impl TestServer { /// Create a fresh server listening on an unused local port for testing. /// /// Returns an object with the local address, as well as a custom [`Drop`] /// implementation that gracefully shuts down the server. pub async fn new() -> Self { let listener = TcpListener::bind("[::1]:0").await.unwrap(); let local_addr = listener.local_addr().unwrap(); let server = Arc::new(Server::new(Default::default()).unwrap()); { let server = Arc::clone(&server); let listener = listener.tap_io(|tcp_stream| { _ = tcp_stream.set_nodelay(true); }); tokio::spawn(async move { server.listen(listener).await.unwrap(); }); } TestServer { local_addr, server } } /// Returns the local TCP address of this server. pub fn local_addr(&self) -> SocketAddr { self.local_addr } /// Returns the HTTP/2 base endpoint URI for this server. pub fn endpoint(&self) -> String { format!("http://{}", self.local_addr) } /// Returns the WebSocket endpoint for streaming connections to a session. pub fn ws_endpoint(&self, name: &str) -> String { format!("ws://{}/api/s/{}", self.local_addr, name) } /// Creates a gRPC client connected to this server. pub async fn grpc_client(&self) -> SshxServiceClient { SshxServiceClient::connect(self.endpoint()).await.unwrap() } /// Return the current server state object. pub fn state(&self) -> Arc { self.server.state() } } impl Drop for TestServer { fn drop(&mut self) { self.server.shutdown(); } } /// A WebSocket client that interacts with the server, used for testing. pub struct ClientSocket { inner: WebSocketStream>, encrypt: Encrypt, write_encrypt: Option, pub user_id: Uid, pub users: BTreeMap, pub shells: BTreeMap, pub data: HashMap, pub messages: Vec<(Uid, String, String)>, pub errors: Vec, } impl ClientSocket { /// Connect to a WebSocket endpoint. pub async fn connect(uri: &str, key: &str, write_password: Option<&str>) -> Result { let (stream, resp) = tokio_tungstenite::connect_async(uri).await?; ensure!(resp.status() == StatusCode::SWITCHING_PROTOCOLS); let mut this = Self { inner: stream, encrypt: Encrypt::new(key), write_encrypt: write_password.map(Encrypt::new), user_id: Uid(0), users: BTreeMap::new(), shells: BTreeMap::new(), data: HashMap::new(), messages: Vec::new(), errors: Vec::new(), }; this.authenticate().await; Ok(this) } async fn authenticate(&mut self) { let encrypted_zeros = self.encrypt.zeros().into(); let write_zeros = self.write_encrypt.as_ref().map(|e| e.zeros().into()); self.send(WsClient::Authenticate(encrypted_zeros, write_zeros)) .await; } pub async fn send(&mut self, msg: WsClient) { let mut buf = Vec::new(); ciborium::ser::into_writer(&msg, &mut buf).unwrap(); self.inner.send(Message::Binary(buf.into())).await.unwrap(); } pub async fn send_input(&mut self, id: Sid, data: &[u8]) { let offset = 42; // arbitrary, don't reuse the offset in real code though let data = self.encrypt.segment(0x200000000, offset, data); self.send(WsClient::Data(id, data.into(), offset)).await; } async fn recv(&mut self) -> Option { loop { match self.inner.next().await.transpose().unwrap() { Some(Message::Text(_)) => panic!("unexpected text message over WebSocket"), Some(Message::Binary(msg)) => { break Some(ciborium::de::from_reader(&*msg).unwrap()) } Some(_) => (), // ignore other message types, keep looping None => break None, } } } pub async fn expect_close(&mut self, code: u16) { let msg = self.inner.next().await.unwrap().unwrap(); match msg { Message::Close(Some(frame)) => assert!(frame.code == code.into()), _ => panic!("unexpected non-close message over WebSocket: {:?}", msg), } } pub async fn flush(&mut self) { const FLUSH_DURATION: Duration = Duration::from_millis(50); let flush_task = async { while let Some(msg) = self.recv().await { match msg { WsServer::Hello(user_id, _) => self.user_id = user_id, WsServer::InvalidAuth() => panic!("invalid authentication"), WsServer::Users(users) => self.users = BTreeMap::from_iter(users), WsServer::UserDiff(id, maybe_user) => { self.users.remove(&id); if let Some(user) = maybe_user { self.users.insert(id, user); } } WsServer::Shells(shells) => self.shells = BTreeMap::from_iter(shells), WsServer::Chunks(id, seqnum, chunks) => { let value = self.data.entry(id).or_default(); assert_eq!(seqnum, value.len() as u64); for buf in chunks { let plaintext = self.encrypt.segment( 0x100000000 | id.0 as u64, value.len() as u64, &buf, ); value.push_str(std::str::from_utf8(&plaintext).unwrap()); } } WsServer::Hear(id, name, msg) => { self.messages.push((id, name, msg)); } WsServer::ShellLatency(_) => {} WsServer::Pong(_) => {} WsServer::Error(err) => self.errors.push(err), } } }; time::timeout(FLUSH_DURATION, flush_task).await.ok(); } pub fn read(&self, id: Sid) -> &str { self.data.get(&id).map(|s| &**s).unwrap_or("") } } ================================================ FILE: crates/sshx-server/tests/simple.rs ================================================ use anyhow::Result; use sshx::encrypt::Encrypt; use sshx_core::proto::*; use crate::common::*; pub mod common; #[tokio::test] async fn test_rpc() -> Result<()> { let server = TestServer::new().await; let mut client = server.grpc_client().await; let req = OpenRequest { origin: "sshx.io".into(), encrypted_zeros: Encrypt::new("").zeros().into(), name: String::new(), write_password_hash: None, }; let resp = client.open(req).await?; assert!(!resp.into_inner().name.is_empty()); Ok(()) } #[tokio::test] async fn test_web_get() -> Result<()> { let server = TestServer::new().await; let resp = reqwest::get(server.endpoint()).await?; assert!(!resp.status().is_server_error()); Ok(()) } ================================================ FILE: crates/sshx-server/tests/snapshot.rs ================================================ use std::sync::Arc; use anyhow::Result; use sshx::{controller::Controller, runner::Runner}; use sshx_core::{Sid, Uid}; use sshx_server::{ session::Session, web::protocol::{WsClient, WsWinsize}, }; use crate::common::*; pub mod common; #[tokio::test] async fn test_basic_restore() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; s.flush().await; assert_eq!(s.user_id, Uid(1)); s.send(WsClient::Create(0, 0)).await; s.flush().await; let new_size = WsWinsize { x: 42, y: 105, rows: 200, cols: 20, }; s.send_input(Sid(1), b"hello there!").await; s.send_input(Sid(1), b" - another message").await; s.send(WsClient::Move(Sid(1), Some(new_size))).await; s.flush().await; assert!(s.shells.contains_key(&Sid(1))); // Replace the shell with its snapshot. let data = server.state().lookup(&name).unwrap().snapshot()?; server .state() .insert(&name, Arc::new(Session::restore(&data)?)); let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; s.send(WsClient::Subscribe(Sid(1), 0)).await; s.flush().await; assert_eq!(s.read(Sid(1)), "hello there! - another message"); assert_eq!(s.shells.get(&Sid(1)).unwrap(), &new_size); Ok(()) } ================================================ FILE: crates/sshx-server/tests/with_client.rs ================================================ use anyhow::{Context, Result}; use sshx::{controller::Controller, encrypt::Encrypt, runner::Runner}; use sshx_core::{ proto::{server_update::ServerMessage, NewShell, TerminalInput}, Sid, Uid, }; use sshx_server::web::protocol::{WsClient, WsWinsize}; use tokio::time::{self, Duration}; use crate::common::*; pub mod common; #[tokio::test] async fn test_handshake() -> Result<()> { let server = TestServer::new().await; let controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; controller.close().await?; Ok(()) } #[tokio::test] async fn test_command() -> Result<()> { let server = TestServer::new().await; let runner = Runner::Shell("/bin/bash".into()); let mut controller = Controller::new(&server.endpoint(), "", runner, false).await?; let session = server .state() .lookup(controller.name()) .context("couldn't find session in server state")?; let updates = session.update_tx(); let new_shell = NewShell { id: 1, x: 0, y: 0 }; updates.send(ServerMessage::CreateShell(new_shell)).await?; let key = controller.encryption_key(); let encrypt = Encrypt::new(key); let offset = 4242; let data = TerminalInput { id: 1, data: encrypt.segment(0x200000000, offset, b"ls\r\n").into(), offset, }; updates.send(ServerMessage::Input(data)).await?; tokio::select! { _ = controller.run() => (), _ = time::sleep(Duration::from_millis(1000)) => (), }; controller.close().await?; Ok(()) } #[tokio::test] async fn test_ws_missing() -> Result<()> { let server = TestServer::new().await; let bad_endpoint = format!("ws://{}/not/an/endpoint", server.local_addr()); assert!(ClientSocket::connect(&bad_endpoint, "", None) .await .is_err()); let mut s = ClientSocket::connect(&server.ws_endpoint("foobar"), "", None).await?; s.expect_close(4404).await; Ok(()) } #[tokio::test] async fn test_ws_basic() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; s.flush().await; assert_eq!(s.user_id, Uid(1)); s.send(WsClient::Create(0, 0)).await; s.flush().await; assert_eq!(s.shells.len(), 1); assert!(s.shells.contains_key(&Sid(1))); s.send(WsClient::Subscribe(Sid(1), 0)).await; assert_eq!(s.read(Sid(1)), ""); s.send_input(Sid(1), b"hello!").await; s.flush().await; assert_eq!(s.read(Sid(1)), "hello!"); s.send_input(Sid(1), b" 123").await; s.flush().await; assert_eq!(s.read(Sid(1)), "hello! 123"); Ok(()) } #[tokio::test] async fn test_ws_resize() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; s.send(WsClient::Move(Sid(1), None)).await; // error: does not exist yet! s.flush().await; assert_eq!(s.errors.len(), 1); s.send(WsClient::Create(0, 0)).await; s.flush().await; assert_eq!(s.shells.len(), 1); assert_eq!(*s.shells.get(&Sid(1)).unwrap(), WsWinsize::default()); let new_size = WsWinsize { x: 42, y: 105, rows: 200, cols: 20, }; s.send(WsClient::Move(Sid(1), Some(new_size))).await; s.send(WsClient::Move(Sid(2), Some(new_size))).await; // error: does not exist s.flush().await; assert_eq!(s.shells.len(), 1); assert_eq!(*s.shells.get(&Sid(1)).unwrap(), new_size); assert_eq!(s.errors.len(), 2); s.send(WsClient::Close(Sid(1))).await; s.flush().await; assert_eq!(s.shells.len(), 0); s.send(WsClient::Move(Sid(1), None)).await; // error: shell was closed s.flush().await; assert_eq!(s.errors.len(), 3); Ok(()) } #[tokio::test] async fn test_users_join() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let endpoint = server.ws_endpoint(&name); let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?; s1.flush().await; assert_eq!(s1.users.len(), 1); let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?; s2.flush().await; assert_eq!(s2.users.len(), 2); drop(s2); let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?; s3.flush().await; assert_eq!(s3.users.len(), 2); s1.flush().await; assert_eq!(s1.users.len(), 2); Ok(()) } #[tokio::test] async fn test_users_metadata() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let endpoint = server.ws_endpoint(&name); let mut s = ClientSocket::connect(&endpoint, &key, None).await?; s.flush().await; assert_eq!(s.users.len(), 1); assert_eq!(s.users.get(&s.user_id).unwrap().cursor, None); s.send(WsClient::SetName("mr. foo".into())).await; s.send(WsClient::SetCursor(Some((40, 524)))).await; s.flush().await; let user = s.users.get(&s.user_id).unwrap(); assert_eq!(user.name, "mr. foo"); assert_eq!(user.cursor, Some((40, 524))); Ok(()) } #[tokio::test] async fn test_chat_messages() -> Result<()> { let server = TestServer::new().await; let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); tokio::spawn(async move { controller.run().await }); let endpoint = server.ws_endpoint(&name); let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?; let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?; s1.send(WsClient::SetName("billy".into())).await; s1.send(WsClient::Chat("hello there!".into())).await; s1.flush().await; s2.flush().await; assert_eq!(s2.messages.len(), 1); assert_eq!( s2.messages[0], (s1.user_id, "billy".into(), "hello there!".into()) ); let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?; s3.flush().await; assert_eq!(s1.messages.len(), 1); assert_eq!(s3.messages.len(), 0); Ok(()) } #[tokio::test] async fn test_read_write_permissions() -> Result<()> { let server = TestServer::new().await; // create controller with read-only mode enabled let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, true).await?; let name = controller.name().to_owned(); let key = controller.encryption_key().to_owned(); let write_url = controller .write_url() .expect("Should have write URL when enable_readers is true") .to_string(); tokio::spawn(async move { controller.run().await }); let write_password = write_url .split(',') .nth(1) .expect("Write URL should contain password"); // connect with write access let mut writer = ClientSocket::connect(&server.ws_endpoint(&name), &key, Some(write_password)).await?; writer.flush().await; // test write permissions writer.send(WsClient::Create(0, 0)).await; writer.flush().await; assert_eq!( writer.shells.len(), 1, "Writer should be able to create a shell" ); assert!(writer.errors.is_empty(), "Writer should not receive errors"); // connect with read-only access let mut reader = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?; reader.flush().await; // test read-only restrictions reader.send(WsClient::Create(0, 0)).await; reader.flush().await; assert!( !reader.errors.is_empty(), "Reader should receive an error when attempting to create shell" ); assert_eq!( reader.shells.len(), 1, "Reader should still see the existing shell" ); Ok(()) } ================================================ FILE: fly.toml ================================================ app = "sshx" primary_region = "ewr" kill_signal = "SIGINT" kill_timeout = 90 [experimental] auto_rollback = true cmd = ["sh", "-c", "./sshx-server --listen :: --host \"$FLY_ALLOC_ID.vm.sshx.internal:8051\""] [[services]] protocol = "tcp" internal_port = 8051 processes = ["app"] [services.concurrency] type = "connections" hard_limit = 65536 soft_limit = 1024 [[services.ports]] port = 80 handlers = ["http"] force_https = true [[services.ports]] port = 443 handlers = ["tls"] [services.ports.tls_options] alpn = ["h2", "http/1.1"] [[services.tcp_checks]] interval = "15s" timeout = "2s" grace_period = "1s" restart_limit = 0 ================================================ FILE: mprocs.yaml ================================================ # prettier-ignore procs: server: shell: >- cargo run --bin sshx-server -- --override-origin http://localhost:5173 --secret dev-secret --redis-url redis://localhost:12601 client: shell: >- cargo run --bin sshx -- --server http://localhost:8051 web: shell: npm run dev stop: SIGKILL # TODO: Why is this necessary? ================================================ FILE: package.json ================================================ { "name": "sshx", "private": true, "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." }, "dependencies": { "@fontsource-variable/inter": "^5.0.8", "@rgossiaux/svelte-headlessui": "^2.0.0", "@tldraw/vec": "^1.9.2", "@use-gesture/vanilla": "^10.2.27", "argon2-browser": "^1.18.0", "buffer": "^6.0.3", "cbor-x": "^1.6.0", "firacode": "^6.2.0", "fontfaceobserver": "^2.3.0", "lodash-es": "^4.17.21", "perfect-cursors": "^1.0.5", "sshx-xterm": "5.2.1", "svelte": "^3.59.2", "svelte-feather-icons": "^4.0.1", "svelte-persisted-store": "^0.7.0", "xterm-addon-image": "^0.5.0", "xterm-addon-web-links": "^0.9.0", "xterm-addon-webgl": "^0.16.0" }, "devDependencies": { "@sveltejs/adapter-static": "^2.0.3", "@sveltejs/kit": "^1.24.1", "@types/lodash-es": "^4.17.9", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", "autoprefixer": "^10.4.15", "cssnano": "^6.0.1", "eslint": "^8.49.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-svelte3": "^4.0.0", "postcss": "^8.4.29", "postcss-load-config": "^4.0.1", "prettier": "2.8.8", "prettier-plugin-svelte": "2.10.1", "svelte-check": "^3.5.1", "svelte-preprocess": "^5.0.4", "tailwindcss": "^3.3.3", "tslib": "^2.6.2", "typescript": "~5.2.2", "vite": "^4.4.9" } } ================================================ FILE: postcss.config.cjs ================================================ const tailwindcss = require("tailwindcss"); const autoprefixer = require("autoprefixer"); const cssnano = require("cssnano"); const mode = process.env.NODE_ENV; const dev = mode === "development"; const config = { plugins: [ // Some plugins, like tailwindcss/nesting, need to run before Tailwind, tailwindcss(), // But others, like autoprefixer, need to run after, autoprefixer(), !dev && cssnano({ preset: "default" }), ], }; module.exports = config; ================================================ FILE: rustfmt.toml ================================================ unstable_features = true group_imports = "StdExternalCrate" wrap_comments = true format_strings = true normalize_comments = true reorder_impl_items = true ================================================ FILE: scripts/release.sh ================================================ #!/bin/bash # Manually releases the latest binaries to AWS S3. # # This runs on my M1 Macbook Pro with cross-compilation toolchains. I think it's # probably better to replace this script with a CI configuration later. set +e # x86_64: for most Linux servers TARGET_CC=x86_64-unknown-linux-musl-cc \ CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \ cargo build --release --target x86_64-unknown-linux-musl # aarch64: for newer Linux servers TARGET_CC=aarch64-unknown-linux-musl-cc \ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-unknown-linux-musl-gcc \ cargo build --release --target aarch64-unknown-linux-musl # armv6l: for devices like Raspberry Pi Zero W TARGET_CC=arm-unknown-linux-musleabihf-cc \ CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-unknown-linux-musleabihf-gcc \ cargo build --release --target arm-unknown-linux-musleabihf # armv7l: for devices like Oxdroid XU4 TARGET_CC=armv7-unknown-linux-musleabihf-cc \ CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7-unknown-linux-musleabihf-gcc \ cargo build --release --target armv7-unknown-linux-musleabihf # x86_64-apple-darwin: for macOS on Intel SDKROOT=$(xcrun --show-sdk-path) \ MACOSX_DEPLOYMENT_TARGET=$(xcrun --show-sdk-platform-version) \ cargo build --release --target x86_64-apple-darwin # aarch64-apple-darwin: for macOS on Apple Silicon cargo build --release --target aarch64-apple-darwin # x86_64-unknown-freebsd: for FreeBSD cross build --release --target x86_64-unknown-freebsd # *-pc-windows-msvc: for Windows, requires cargo-xwin XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target x86_64-pc-windows-msvc XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target i686-pc-windows-msvc XWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target aarch64-pc-windows-msvc --cross-compiler clang temp=$(mktemp) targets=( x86_64-unknown-linux-musl aarch64-unknown-linux-musl arm-unknown-linux-musleabihf armv7-unknown-linux-musleabihf x86_64-apple-darwin aarch64-apple-darwin x86_64-unknown-freebsd x86_64-pc-windows-msvc i686-pc-windows-msvc aarch64-pc-windows-msvc ) for target in "${targets[@]}" do if [[ ! $target == *"windows"* ]]; then echo "compress: target/$target/release/sshx" tar --no-xattrs -czf $temp -C target/$target/release sshx aws s3 cp $temp s3://sshx/sshx-$target.tar.gz echo "compress: target/$target/release/sshx-server" tar --no-xattrs -czf $temp -C target/$target/release sshx-server aws s3 cp $temp s3://sshx/sshx-server-$target.tar.gz else echo "compress: target/$target/release/sshx.exe" rm $temp && zip -X -j $temp target/$target/release/sshx.exe aws s3 cp $temp s3://sshx/sshx-$target.zip fi done ================================================ FILE: src/app.css ================================================ @font-face { font-family: "Fira Code VF"; src: url("firacode/distr/woff2/FiraCode-VF.woff2") format("woff2-variations"), url("firacode/distr/woff/FiraCode-VF.woff") format("woff-variations"); /* 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 */ font-weight: 300 700; font-style: normal; } @tailwind base; @tailwind components; @tailwind utilities; @layer base { body { color-scheme: dark; } } @layer components { .panel { @apply border border-zinc-800 bg-zinc-900/90 backdrop-blur-sm rounded-xl pointer-events-auto; } } ================================================ FILE: src/app.d.ts ================================================ /// // Injected by vite.config.ts declare const __APP_VERSION__: string; // See https://kit.svelte.dev/docs/types#the-app-namespace // for information about these interfaces declare namespace App { // interface Locals {} // interface Platform {} // interface Session {} // interface Stuff {} } // Type declarations for external libraries. declare module "fontfaceobserver"; ================================================ FILE: src/app.html ================================================ sshx %sveltekit.head%
%sveltekit.body%
================================================ FILE: src/lib/Session.svelte ================================================
event.preventDefault()} >
{ showChat = !showChat; newMessages = false; }} on:settings={() => { settingsOpen = true; }} on:networkInfo={() => { showNetworkInfo = !showNetworkInfo; }} /> {#if showNetworkInfo}
{/if}
{#if showChat}
srocket?.send({ chat: event.detail })} on:close={() => (showChat = false)} />
{/if} (settingsOpen = false)} />
{#if exitReason !== null}
{exitReason}
{:else if connected}
You are connected!
{#if userId && hasWriteAccess === false}
Read-only
{/if}
{:else}
Connecting…
{/if}
{#each shells as [id, winsize] (id)} {@const ws = id === moving ? movingSize : winsize}
hasWriteAccess && handleInput(id, data)} on:close={() => srocket?.send({ close: id })} on:shrink={() => { if (!hasWriteAccess) return; const rows = Math.max(ws.rows - 4, TERM_MIN_ROWS); const cols = Math.max(ws.cols - 10, TERM_MIN_COLS); if (rows !== ws.rows || cols !== ws.cols) { srocket?.send({ move: [id, { ...ws, rows, cols }] }); } }} on:expand={() => { if (!hasWriteAccess) return; const rows = ws.rows + 4; const cols = ws.cols + 10; srocket?.send({ move: [id, { ...ws, rows, cols }] }); }} on:bringToFront={() => { if (!hasWriteAccess) return; showNetworkInfo = false; srocket?.send({ move: [id, null] }); }} on:startMove={({ detail: event }) => { if (!hasWriteAccess) return; const [x, y] = normalizePosition(event); moving = id; movingOrigin = [x - ws.x, y - ws.y]; movingSize = ws; movingIsDone = false; }} on:focus={() => { if (!hasWriteAccess) return; focused = [...focused, id]; }} on:blur={() => { focused = focused.filter((i) => i !== id); }} />
uid !== userId && user.focus === id, )} />
{ const canvasEl = termElements[id].querySelector(".xterm-screen"); if (canvasEl) { resizing = id; const r = canvasEl.getBoundingClientRect(); resizingOrigin = [event.pageX - r.width, event.pageY - r.height]; resizingCell = [r.width / ws.cols, r.height / ws.rows]; resizingSize = ws; } }} on:pointerdown={(event) => event.stopPropagation()} />
{/each} {#each users.filter(([id, user]) => id !== userId && user.cursor !== null) as [id, user] (id)}
{/each}
================================================ FILE: src/lib/action/slide.ts ================================================ import { tweened } from "svelte/motion"; import { cubicOut } from "svelte/easing"; import type { Action } from "svelte/action"; import { PerfectCursor } from "perfect-cursors"; export type SlideParams = { x: number; y: number; center: number[]; zoom: number; immediate?: boolean; }; /** An action for tweened transitions with global transformations. */ export const slide: Action = (node, params) => { let center = params?.center ?? [0, 0]; let zoom = params?.zoom ?? 1; const pos = { x: params?.x ?? 0, y: params?.y ?? 0 }; const spos = tweened(pos, { duration: 150, easing: cubicOut }); const disposeSub = spos.subscribe((pos) => { node.style.transform = `scale(${(zoom * 100).toFixed(3)}%) translate3d(${pos.x - center[0]}px, ${pos.y - center[1]}px, 0)`; }); return { update(params) { center = params?.center ?? [0, 0]; zoom = params?.zoom ?? 1; const pos = { x: params?.x ?? 0, y: params?.y ?? 0 }; spos.set(pos, { duration: params.immediate ? 0 : 150 }); }, destroy() { disposeSub(); node.style.transform = ""; }, }; }; /** * An action using perfect-cursors to transition an element. * * The transitions are really smooth geometrically, but they seem to introduce * too much noticeable delay. Keeping this function for reference. */ export const slideCursor: Action = (node, params) => { const pos = params ?? { x: 0, y: 0 }; const pc = new PerfectCursor(([x, y]: number[]) => { node.style.transform = `translate3d(${x}px, ${y}px, 0)`; }); pc.addPoint([pos.x, pos.y]); return { update(params) { const pos = params ?? { x: 0, y: 0 }; pc.addPoint([pos.x, pos.y]); }, destroy() { pc.dispose(); node.style.transform = ""; }, }; }; ================================================ FILE: src/lib/action/touchZoom.ts ================================================ /** * @file Handles pan and zoom events to create an infinite canvas. * * This file is modified from Dispict , * which itself is loosely based on tldraw. */ import { Gesture, type Handler, type WebKitGestureEvent, } from "@use-gesture/vanilla"; import Vec from "@tldraw/vec"; // Credits: from excalidraw // https://github.com/excalidraw/excalidraw/blob/07ebd7c68ce6ff92ddbc22d1c3d215f2b21328d6/src/utils.ts#L542-L563 const getNearestScrollableContainer = ( element: HTMLElement, ): HTMLElement | Document => { let parent = element.parentElement; while (parent) { if (parent === document.body) { return document; } const { overflowY } = window.getComputedStyle(parent); const hasScrollableContent = parent.scrollHeight > parent.clientHeight; if ( hasScrollableContent && (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") ) { return parent; } parent = parent.parentElement; } return document; }; function isDarwin(): boolean { return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); } function debounce void>(fn: T, ms = 0) { let timeoutId: number | any; return function (...args: Parameters) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(args), ms); }; } const MIN_ZOOM = 0.35; const MAX_ZOOM = 2; export const INITIAL_ZOOM = 1.0; export class TouchZoom { #node: HTMLElement; #scrollingAnchor: HTMLElement | Document; #gesture: Gesture; #resizeObserver: ResizeObserver; #bounds = { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0, }; #originPoint: number[] | undefined = undefined; #delta: number[] = [0, 0]; #lastMovement = 1; #wheelLastTimeStamp = 0; #callbacks = new Set<(manual: boolean) => void>(); isPinching = false; center: number[] = [0, 0]; zoom = INITIAL_ZOOM; #preventGesture = (event: TouchEvent) => event.preventDefault(); constructor(node: HTMLElement) { this.#node = node; this.#scrollingAnchor = getNearestScrollableContainer(node); // @ts-ignore document.addEventListener("gesturestart", this.#preventGesture); // @ts-ignore document.addEventListener("gesturechange", this.#preventGesture); this.#updateBounds(); window.addEventListener("resize", this.#updateBoundsD); this.#scrollingAnchor.addEventListener("scroll", this.#updateBoundsD); this.#resizeObserver = new ResizeObserver((entries) => { if (this.isPinching) return; if (entries[0].contentRect) this.#updateBounds(); }); this.#resizeObserver.observe(node); this.#gesture = new Gesture( node, { onWheel: this.#handleWheel, onPinchStart: this.#handlePinchStart, onPinch: this.#handlePinch, onPinchEnd: this.#handlePinchEnd, onDrag: this.#handleDrag, }, { target: node, eventOptions: { passive: false }, pinch: { from: [this.zoom, 0], scaleBounds: () => { return { from: this.zoom, max: MAX_ZOOM, min: MIN_ZOOM }; }, }, drag: { filterTaps: true, pointer: { keys: false }, }, }, ); } #getPoint(e: PointerEvent | Touch | WheelEvent): number[] { return [ +e.clientX.toFixed(2) - this.#bounds.minX, +e.clientY.toFixed(2) - this.#bounds.minY, ]; } #updateBounds = () => { const rect = this.#node.getBoundingClientRect(); this.#bounds = { minX: rect.left, maxX: rect.left + rect.width, minY: rect.top, maxY: rect.top + rect.height, width: rect.width, height: rect.height, }; }; #updateBoundsD = debounce(this.#updateBounds, 100); onMove(callback: (manual: boolean) => void): () => void { this.#callbacks.add(callback); return () => this.#callbacks.delete(callback); } async moveTo(pos: number[], zoom: number) { // Cubic bezier easing const smoothstep = (z: number) => { const x = Math.max(0, Math.min(1, z)); return x * x * (3 - 2 * x); }; const beginTime = Date.now(); const totalTime = 350; // milliseconds const start = this.center; const startZ = 1 / this.zoom; const finishZ = 1 / zoom; while (true) { const t = Date.now() - beginTime; if (t > totalTime) break; const k = smoothstep(t / totalTime); this.center = Vec.lrp(start, pos, k); this.zoom = 1 / (startZ * (1 - k) + finishZ * k); this.#moved(false); await new Promise((resolve) => requestAnimationFrame(resolve)); } this.center = pos; this.zoom = zoom; this.#moved(false); } #moved(manual = true) { for (const callback of this.#callbacks) { callback(manual); } } #handleWheel: Handler<"wheel", WheelEvent> = ({ event: e }) => { e.preventDefault(); if (this.isPinching || e.timeStamp <= this.#wheelLastTimeStamp) return; this.#wheelLastTimeStamp = e.timeStamp; const [x, y, z] = normalizeWheel(e); // alt+scroll or ctrl+scroll = zoom (when not clicking) if ((e.altKey || e.ctrlKey || e.metaKey) && e.buttons === 0) { const point = e.clientX && e.clientY ? this.#getPoint(e) : [this.#bounds.width / 2, this.#bounds.height / 2]; const delta = z * 0.618; let newZoom = (1 - delta / 320) * this.zoom; newZoom = Vec.clamp(newZoom, MIN_ZOOM, MAX_ZOOM); const offset = Vec.sub(point, [0, 0]); const movement = Vec.mul(offset, 1 / this.zoom - 1 / newZoom); this.center = Vec.add(this.center, movement); this.zoom = newZoom; this.#moved(); return; } // otherwise pan const delta = Vec.mul( e.shiftKey && !isDarwin() ? // shift+scroll = pan horizontally [y, 0] : // scroll = pan vertically (or in any direction on a trackpad) [x, y], 0.5, ); if (Vec.isEqual(delta, [0, 0])) return; this.center = Vec.add(this.center, Vec.div(delta, this.zoom)); this.#moved(); }; #handlePinchStart: Handler< "pinch", WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent > = ({ origin, event }) => { if (event instanceof WheelEvent) return; this.isPinching = true; this.#originPoint = origin; this.#delta = [0, 0]; this.#lastMovement = 1; this.#moved(); }; #handlePinch: Handler< "pinch", WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent > = ({ origin, movement, event }) => { if (event instanceof WheelEvent) return; if (!this.#originPoint) return; const delta = Vec.sub(this.#originPoint, origin); const trueDelta = Vec.sub(delta, this.#delta); this.#delta = delta; const zoomLevel = movement[0] / this.#lastMovement; this.#lastMovement = movement[0]; this.center = Vec.add(this.center, Vec.div(trueDelta, this.zoom * 2)); this.zoom = Vec.clamp(this.zoom * zoomLevel, MIN_ZOOM, MAX_ZOOM); this.#moved(); }; #handlePinchEnd: Handler< "pinch", WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent > = () => { this.isPinching = false; this.#originPoint = undefined; this.#delta = [0, 0]; this.#lastMovement = 1; this.#moved(); }; #handleDrag: Handler< "drag", MouseEvent | PointerEvent | TouchEvent | KeyboardEvent > = ({ delta, elapsedTime }) => { if (delta[0] === 0 && delta[1] === 0 && elapsedTime < 200) return; this.center = Vec.sub(this.center, Vec.div(delta, this.zoom)); this.#moved(); }; destroy() { if (this.#node) { // @ts-ignore document.addEventListener("gesturestart", this.#preventGesture); // @ts-ignore document.addEventListener("gesturechange", this.#preventGesture); window.removeEventListener("resize", this.#updateBoundsD); this.#scrollingAnchor.removeEventListener("scroll", this.#updateBoundsD); this.#resizeObserver.disconnect(); this.#gesture.destroy(); this.#node = null as any; } } } // Reasonable defaults const MAX_ZOOM_STEP = 10; // Adapted from https://stackoverflow.com/a/13650579 function normalizeWheel(event: WheelEvent) { const { deltaY, deltaX } = event; let deltaZ = 0; if (event.ctrlKey || event.metaKey) { const signY = Math.sign(event.deltaY); const absDeltaY = Math.abs(event.deltaY); let dy = deltaY; if (absDeltaY > MAX_ZOOM_STEP) { dy = MAX_ZOOM_STEP * signY; } deltaZ = dy; } return [deltaX, deltaY, deltaZ]; } ================================================ FILE: src/lib/arrange.ts ================================================ const ISECT_W = 752; const ISECT_H = 515; const ISECT_PAD = 16; type ExistingTerminal = { x: number; y: number; width: number; height: number; }; /** Choose a position for a new terminal that does not intersect existing ones. */ export function arrangeNewTerminal(existing: ExistingTerminal[]) { if (existing.length === 0) { return { x: 0, y: 0 }; } const startX = 100 * (Math.random() - 0.5); const startY = 60 * (Math.random() - 0.5); for (let i = 0; ; i++) { const t = 1.94161103872 * i; const x = Math.round(startX + 8 * i * Math.cos(t)); const y = Math.round(startY + 8 * i * Math.sin(t)); let ok = true; for (const box of existing) { if ( isect(x, x + ISECT_W, box.x, box.x + box.width) && isect(y, y + ISECT_H, box.y, box.y + box.height) ) { ok = false; break; } } if (ok) { return { x, y }; } } } function isect(s1: number, e1: number, s2: number, e2: number): boolean { return s1 - ISECT_PAD < e2 && e1 + ISECT_PAD > s2; } ================================================ FILE: src/lib/encrypt.ts ================================================ /** * @file Encryption of byte streams based on a random key. * * This is used for end-to-end encryption between the terminal source and its * client. Keep this file consistent with the Rust implementation. */ const SALT: string = "This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!"; export class Encrypt { private constructor(private aesKey: CryptoKey) {} static async new(key: string): Promise { const argon2 = await import( "argon2-browser/dist/argon2-bundled.min.js" as any ); const result = await argon2.hash({ pass: key, salt: SALT, type: argon2.ArgonType.Argon2id, mem: 19 * 1024, // Memory cost in KiB time: 2, // Number of iterations parallelism: 1, hashLen: 16, // Hash length in bytes }); const aesKey = await crypto.subtle.importKey( "raw", Uint8Array.from( result.hashHex .match(/.{1,2}/g) .map((byte: string) => parseInt(byte, 16)), ), { name: "AES-CTR" }, false, ["encrypt"], ); return new Encrypt(aesKey); } async zeros(): Promise { const zeros = new Uint8Array(16); const cipher = await crypto.subtle.encrypt( { name: "AES-CTR", counter: zeros, length: 64 }, this.aesKey, zeros, ); return new Uint8Array(cipher); } async segment( streamNum: bigint, offset: bigint, data: Uint8Array, ): Promise { if (streamNum === 0n) throw new Error("stream number must be nonzero"); // security check) const blockNum = offset >> 4n; const iv = new Uint8Array(16); new DataView(iv.buffer).setBigUint64(0, streamNum); new DataView(iv.buffer).setBigUint64(8, blockNum); const padBytes = Number(offset % 16n); const paddedData = new Uint8Array(padBytes + data.length); paddedData.set(data, padBytes); const encryptedData = await crypto.subtle.encrypt( { name: "AES-CTR", counter: iv, length: 64, }, this.aesKey, paddedData, ); return new Uint8Array(encryptedData, padBytes, data.length); } } ================================================ FILE: src/lib/lock.ts ================================================ // Simple async lock for use in streaming encryption. // See . export function createLock() { const queue: (() => Promise)[] = []; let active = false; return (fn: () => Promise) => { let deferredResolve: any; let deferredReject: any; const deferred = new Promise((resolve, reject) => { deferredResolve = resolve; deferredReject = reject; }); const exec = async () => { await fn().then(deferredResolve, deferredReject); if (queue.length > 0) { queue.shift()!(); } else { active = false; } }; if (active) { queue.push(exec); } else { active = true; exec(); } return deferred; }; } ================================================ FILE: src/lib/protocol.ts ================================================ type Sid = number; // u32 type Uid = number; // u32 /** Position and size of a window, see the Rust version. */ export type WsWinsize = { x: number; y: number; rows: number; cols: number; }; /** Information about a user, see the Rust version */ export type WsUser = { name: string; cursor: [number, number] | null; focus: number | null; canWrite: boolean; }; /** Server message type, see the Rust version. */ export type WsServer = { hello?: [Uid, string]; invalidAuth?: []; users?: [Uid, WsUser][]; userDiff?: [Uid, WsUser | null]; shells?: [Sid, WsWinsize][]; chunks?: [Sid, number, Uint8Array[]]; hear?: [Uid, string, string]; shellLatency?: number | bigint; pong?: number | bigint; error?: string; }; /** Client message type, see the Rust version. */ export type WsClient = { authenticate?: [Uint8Array, Uint8Array | null]; setName?: string; setCursor?: [number, number] | null; setFocus?: number | null; create?: [number, number]; close?: Sid; move?: [Sid, WsWinsize | null]; data?: [Sid, Uint8Array, bigint]; subscribe?: [Sid, number]; chat?: string; ping?: bigint; }; ================================================ FILE: src/lib/settings.ts ================================================ import { persisted } from "svelte-persisted-store"; import themes, { type ThemeName, defaultTheme } from "./ui/themes"; import { derived, type Readable } from "svelte/store"; export type Settings = { name: string; theme: ThemeName; scrollback: number; }; const storedSettings = persisted>("sshx-settings-store", {}); /** A persisted store for settings of the current user. */ export const settings: Readable = derived( storedSettings, ($storedSettings) => { // Do some validation on all of the stored settings. const name = $storedSettings.name ?? ""; let theme = $storedSettings.theme; if (!theme || !Object.hasOwn(themes, theme)) { theme = defaultTheme; } let scrollback = $storedSettings.scrollback; if (typeof scrollback !== "number" || scrollback < 0) { scrollback = 5000; } return { name, theme, scrollback, }; }, ); export function updateSettings(values: Partial) { storedSettings.update((settings) => ({ ...settings, ...values })); } ================================================ FILE: src/lib/srocket.ts ================================================ /** * @file Internal library for sshx, providing real-time communication. * * The contents of this file are technically general, not sshx-specific, but it * is not open-sourced as its own library because it's not ready for that. */ import { encode, decode } from "cbor-x"; /** How long to wait between reconnections (in milliseconds). */ const RECONNECT_DELAY = 500; /** Number of messages to queue while disconnected. */ const BUFFER_SIZE = 64; export type SrocketOptions = { /** Handle a message received from the server. */ onMessage(message: T): void; /** Called when the socket connects to the server. */ onConnect?(): void; /** Called when a connected socket is closed. */ onDisconnect?(): void; /** Called when an incoming or existing connection is closed. */ onClose?(event: CloseEvent): void; }; /** A reconnecting WebSocket client for real-time communication. */ export class Srocket { #url: string; #options: SrocketOptions; #ws: WebSocket | null; #connected: boolean; #buffer: Uint8Array[]; #disposed: boolean; constructor(url: string, options: SrocketOptions) { this.#url = url; if (this.#url.startsWith("/")) { // Get WebSocket URL relative to the current origin. this.#url = (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + this.#url; } this.#options = options; this.#ws = null; this.#connected = false; this.#buffer = []; this.#disposed = false; this.#reconnect(); } get connected() { return this.#connected; } /** Queue a message to send to the server, with "at-most-once" semantics. */ send(message: U) { // Types in cbor-x are incorrect here, so cast to fix the error. // See: https://github.com/kriszyp/cbor-x/issues/120 const data = (encode(message) as unknown); if (this.#connected && this.#ws) { this.#ws.send(data); } else { if (this.#buffer.length < BUFFER_SIZE) { this.#buffer.push(data); } } } /** Dispose of this WebSocket permanently. */ dispose() { this.#stateChange(false); this.#disposed = true; this.#ws?.close(); } #reconnect() { if (this.#disposed) return; if (this.#ws !== null) { throw new Error("invariant violation: reconnecting while connected"); } this.#ws = new WebSocket(this.#url); this.#ws.binaryType = "arraybuffer"; this.#ws.onopen = () => { this.#stateChange(true); }; this.#ws.onclose = (event) => { this.#options.onClose?.(event); this.#ws = null; this.#stateChange(false); setTimeout(() => this.#reconnect(), RECONNECT_DELAY); }; this.#ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { const message: T = decode(new Uint8Array(event.data)); this.#options.onMessage(message); } else { console.warn("unexpected non-buffer message, ignoring"); } }; } #stateChange(connected: boolean) { if (!this.#disposed && connected !== this.#connected) { this.#connected = connected; if (connected) { this.#options.onConnect?.(); if (!this.#ws) { throw new Error("invariant violation: connected but ws is null"); } // Send any queued messages. for (const message of this.#buffer) { this.#ws.send(message); } this.#buffer = []; } else { this.#options.onDisconnect?.(); } } } } ================================================ FILE: src/lib/toast.ts ================================================ /** @file Provides a simple, native toast library. */ import { writable } from "svelte/store"; export const toastStore = writable<(Toast & { expires: number })[]>([]); export type Toast = { kind: "info" | "success" | "error"; message: string; action?: string; onAction?: () => void; }; export function makeToast(toast: Toast, duration = 3000) { const obj = Object.assign({ expires: Date.now() + duration }, toast); toastStore.update(($toasts) => [...$toasts, obj]); } ================================================ FILE: src/lib/typeahead.ts ================================================ // A terminal "local echo" or typeahead addon for xterm.js. // // This is forked from VSCode's typeahead implementation at // https://github.com/microsoft/vscode/blob/1.80.1/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts // // I made this mostly standalone by porting over the "vs" common libraries. import type { IBuffer, IBufferCell, IDisposable, ITerminalAddon, Terminal, } from "sshx-xterm"; ///// BEGIN PORTS FROM PACKAGES vs/base/* ///// /** Simplified port of vscode's Disposable class, from `vs/base/common/lifecycle.ts`. */ abstract class Disposable implements IDisposable { protected isDisposed = false; protected readonly store: IDisposable[] = []; dispose(): void { if (!this.isDisposed) { this.isDisposed = true; for (const d of this.store) { d.dispose(); } } } protected _register(o: T): T { if ((o as unknown as Disposable) == this) { throw new Error("cannot _register a Disposable on itself"); } if (this.isDisposed) { console.warn( new Error("trying to _register on a Disposable that is disposed"), ); } else { this.store.push(o); } return o; } } /** Port of vscode's toDisposable function, from `vs/base/common/lifecycle.ts`. */ function toDisposable(fn: () => void): IDisposable { let isDisposed = false; return { dispose() { if (!isDisposed) { isDisposed = true; fn(); } }, }; } /** Port from `vs/base/common/async.ts`. */ function disposableTimeout(handler: () => void, timeout = 0): IDisposable { const timer = setTimeout(handler, timeout); return toDisposable(() => clearTimeout(timer)); } /** * An event with zero or one parameters that ca n be subscribed to. The event is a function itself. * * Simplified port from `vs/base/common/event.ts`. */ interface Event { (listener: (e: T) => any): IDisposable; } /** Very simplified port (rewrite) from `vs/base/common/event.ts`. */ class Emitter { private _disposed = false; private _event?: Event; private _listeners: ((e: T) => any)[] = []; dispose() { if (!this._disposed) { this._disposed = true; } } get event(): Event { this._event ??= (callback: (e: T) => any) => { if (this._disposed) { return toDisposable(() => {}); } this._listeners.push(callback); return toDisposable(() => { this._listeners = this._listeners.filter((l) => l !== callback); }); }; return this._event; } fire(event: T): void { if (this._disposed) return; for (const listener of this._listeners) { listener(event); } } } /** * Escapes regular expression characters in a given string, from `vs/base/common/strings.ts`. */ function escapeRegExpCharacters(value: string): string { // eslint-disable-next-line no-useless-escape return value.replace(/[\\\{\}\*\+\?\|\^\$\.\[\]\(\)]/g, "\\$&"); } /** Port from `vs/base/common/decorators.ts` */ function createDecorator( mapFn: (fn: Function, key: string) => Function, ): Function { return (target: any, key: string, descriptor: any) => { let fnKey: string | null = null; let fn: Function | null = null; if (typeof descriptor.value === "function") { fnKey = "value"; fn = descriptor.value; } else if (typeof descriptor.get === "function") { fnKey = "get"; fn = descriptor.get; } if (!fn) { throw new Error("not supported"); } descriptor[fnKey!] = mapFn(fn, key); }; } /** Port from `vs/base/common/decorators.ts` */ interface IDebounceReducer { (previousValue: T, ...args: any[]): T; } /** Port from `vs/base/common/decorators.ts` */ function debounce( delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T, ): Function { return createDecorator((fn, key) => { const timerKey = `$debounce$${key}`; const resultKey = `$debounce$result$${key}`; return function (this: any, ...args: any[]) { if (!this[resultKey]) { this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; } clearTimeout(this[timerKey]); if (reducer) { this[resultKey] = reducer(this[resultKey], ...args); args = [this[resultKey]]; } this[timerKey] = setTimeout(() => { fn.apply(this, args); this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; }, delay); }; }); } ///// END PORTS FROM PACKAGES vs/base/* ///// const enum VT { Esc = "\x1b", Csi = `\x1b[`, ShowCursor = `\x1b[?25h`, HideCursor = `\x1b[?25l`, DeleteChar = `\x1b[X`, DeleteRestOfLine = `\x1b[K`, } const CSI_STYLE_RE = /^\x1b\[[0-9;]*m/; const CSI_MOVE_RE = /^\x1b\[?([0-9]*)(;[35])?O?([DC])/; const NOT_WORD_RE = /[^a-z0-9]/i; const enum StatsConstants { StatsBufferSize = 24, StatsSendTelemetryEvery = 1000 * 60 * 5, // how often to collect stats StatsMinSamplesToTurnOn = 5, StatsMinAccuracyToTurnOn = 0.3, StatsToggleOffThreshold = 0.5, // if latency is less than `threshold * this`, turn off } /** * Codes that should be omitted from sending to the prediction engine and instead omitted directly: * - Hide cursor (DECTCEM): We wrap the local echo sequence in hide and show * CSI ? 2 5 l * - Show cursor (DECTCEM): We wrap the local echo sequence in hide and show * CSI ? 2 5 h * - Device Status Report (DSR): These sequence fire report events from xterm which could cause * double reporting and potentially a stack overflow (#119472) * CSI Ps n * CSI ? Ps n */ const PREDICTION_OMIT_RE = /^(\x1b\[(\??25[hl]|\??[0-9;]+n))+/; const core = (terminal: Terminal): any => (terminal as any)._core; // => IXtermCore const flushOutput = (terminal: Terminal) => { // TODO: Flushing output is not possible anymore without async void terminal; }; const enum CursorMoveDirection { Back = "D", Forwards = "C", } interface ICoordinate { x: number; y: number; baseY: number; } class Cursor implements ICoordinate { private _x = 0; private _y = 1; private _baseY = 1; get x() { return this._x; } get y() { return this._y; } get baseY() { return this._baseY; } get coordinate(): ICoordinate { return { x: this._x, y: this._y, baseY: this._baseY }; } constructor( readonly rows: number, readonly cols: number, private readonly _buffer: IBuffer, ) { this._x = _buffer.cursorX; this._y = _buffer.cursorY; this._baseY = _buffer.baseY; } getLine() { return this._buffer.getLine(this._y + this._baseY); } getCell(loadInto?: IBufferCell) { return this.getLine()?.getCell(this._x, loadInto); } moveTo(coordinate: ICoordinate) { this._x = coordinate.x; this._y = coordinate.y + coordinate.baseY - this._baseY; return this.moveInstruction(); } clone() { const c = new Cursor(this.rows, this.cols, this._buffer); c.moveTo(this); return c; } move(x: number, y: number) { this._x = x; this._y = y; return this.moveInstruction(); } shift(x: number = 0, y: number = 0) { this._x += x; this._y += y; return this.moveInstruction(); } moveInstruction() { if (this._y >= this.rows) { this._baseY += this._y - (this.rows - 1); this._y = this.rows - 1; } else if (this._y < 0) { this._baseY -= this._y; this._y = 0; } return `${VT.Csi}${this._y + 1};${this._x + 1}H`; } } const moveToWordBoundary = (b: IBuffer, cursor: Cursor, direction: -1 | 1) => { let ateLeadingWhitespace = false; if (direction < 0) { cursor.shift(-1); } let cell: IBufferCell | undefined; while (cursor.x >= 0) { cell = cursor.getCell(cell); if (!cell?.getCode()) { return; } const chars = cell.getChars(); if (NOT_WORD_RE.test(chars)) { if (ateLeadingWhitespace) { break; } } else { ateLeadingWhitespace = true; } cursor.shift(direction); } if (direction < 0) { cursor.shift(1); // we want to place the cursor after the whitespace starting the word } }; const enum MatchResult { /** matched successfully */ Success, /** failed to match */ Failure, /** buffer data, it might match in the future one more data comes in */ Buffer, } export interface IPrediction { /** * Whether applying this prediction can modify the style attributes of the * terminal. If so it means we need to reset the cursor style if it's * rolled back. */ readonly affectsStyle?: boolean; /** * If set to false, the prediction will not be cleared if no input is * received from the server. */ readonly clearAfterTimeout?: boolean; /** * Returns a sequence to apply the prediction. * @param buffer to write to * @param cursor position to write the data. Should advance the cursor. * @returns a string to be written to the user terminal, or optionally a * string for the user terminal and real pty. */ apply(buffer: IBuffer, cursor: Cursor): string; /** * Returns a sequence to roll back a previous `apply()` call. If * `rollForwards` is not given, then this is also called if a prediction * is correct before show the user's data. */ rollback(cursor: Cursor): string; /** * If available, this will be called when the prediction is correct. */ rollForwards(cursor: Cursor, withInput: string): string; /** * Returns whether the given input is one expected by this prediction. * @param input reader for the input the PTY is giving * @param lookBehind the last successfully-made prediction, if any */ matches(input: StringReader, lookBehind?: IPrediction): MatchResult; } class StringReader { index = 0; get remaining() { return this._input.length - this.index; } get eof() { return this.index === this._input.length; } get rest() { return this._input.slice(this.index); } constructor(private readonly _input: string) {} /** * Advances the reader and returns the character if it matches. */ eatChar(char: string) { if (this._input[this.index] !== char) { return; } this.index++; return char; } /** * Advances the reader and returns the string if it matches. */ eatStr(substr: string) { if (this._input.slice(this.index, substr.length) !== substr) { return; } this.index += substr.length; return substr; } /** * Matches and eats the substring character-by-character. If EOF is reached * before the substring is consumed, it will buffer. Index is not moved * if it's not a match. */ eatGradually(substr: string): MatchResult { const prevIndex = this.index; for (let i = 0; i < substr.length; i++) { if (i > 0 && this.eof) { return MatchResult.Buffer; } if (!this.eatChar(substr[i])) { this.index = prevIndex; return MatchResult.Failure; } } return MatchResult.Success; } /** * Advances the reader and returns the regex if it matches. */ eatRe(re: RegExp) { const match = re.exec(this._input.slice(this.index)); if (!match) { return; } this.index += match[0].length; return match; } /** * Advances the reader and returns the character if the code matches. */ eatCharCode(min = 0, max = min + 1) { const code = this._input.charCodeAt(this.index); if (code < min || code >= max) { return undefined; } this.index++; return code; } } /** * Preidction which never tests true. Will always discard predictions made * after it. */ class HardBoundary implements IPrediction { readonly clearAfterTimeout = false; apply() { return ""; } rollback() { return ""; } rollForwards() { return ""; } matches() { return MatchResult.Failure; } } /** * Wraps another prediction. Does not apply the prediction, but will pass * through its `matches` request. */ class TentativeBoundary implements IPrediction { private _appliedCursor?: Cursor; constructor(readonly inner: IPrediction) {} apply(buffer: IBuffer, cursor: Cursor) { this._appliedCursor = cursor.clone(); this.inner.apply(buffer, this._appliedCursor); return ""; } rollback(cursor: Cursor) { this.inner.rollback(cursor.clone()); return ""; } rollForwards(cursor: Cursor, withInput: string) { if (this._appliedCursor) { cursor.moveTo(this._appliedCursor); } return withInput; } matches(input: StringReader) { return this.inner.matches(input); } } const isTenativeCharacterPrediction = ( p: unknown, ): p is TentativeBoundary & { inner: CharacterPrediction } => p instanceof TentativeBoundary && p.inner instanceof CharacterPrediction; /** * Prediction for a single alphanumeric character. */ class CharacterPrediction implements IPrediction { readonly affectsStyle = true; appliedAt?: { pos: ICoordinate; oldAttributes: string; oldChar: string; }; constructor( private readonly _style: TypeAheadStyle, private readonly _char: string, ) {} apply(_: IBuffer, cursor: Cursor) { const cell = cursor.getCell(); this.appliedAt = cell ? { pos: cursor.coordinate, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars(), } : { pos: cursor.coordinate, oldAttributes: "", oldChar: "" }; cursor.shift(1); return this._style.apply + this._char + this._style.undo; } rollback(cursor: Cursor) { if (!this.appliedAt) { return ""; // not applied } const { oldAttributes, oldChar, pos } = this.appliedAt; const r = cursor.moveTo(pos) + (oldChar ? `${oldAttributes}${oldChar}${cursor.moveTo(pos)}` : VT.DeleteChar); return r; } rollForwards(cursor: Cursor, input: string) { if (!this.appliedAt) { return ""; // not applied } return cursor.clone().moveTo(this.appliedAt.pos) + input; } matches(input: StringReader, lookBehind?: IPrediction) { const startIndex = input.index; // remove any styling CSI before checking the char while (input.eatRe(CSI_STYLE_RE)) {} if (input.eof) { return MatchResult.Buffer; } if (input.eatChar(this._char)) { return MatchResult.Success; } if (lookBehind instanceof CharacterPrediction) { // see #112842 const sillyZshOutcome = input.eatGradually( `\b${lookBehind._char}${this._char}`, ); if (sillyZshOutcome !== MatchResult.Failure) { return sillyZshOutcome; } } input.index = startIndex; return MatchResult.Failure; } } class BackspacePrediction implements IPrediction { protected _appliedAt?: { pos: ICoordinate; oldAttributes: string; oldChar: string; isLastChar: boolean; }; constructor(private readonly _terminal: Terminal) {} apply(_: IBuffer, cursor: Cursor) { // at eol if everything to the right is whitespace (zsh will emit a "clear line" code in this case) // todo: can be optimized if `getTrimmedLength` is exposed from xterm const isLastChar = !cursor .getLine() ?.translateToString(undefined, cursor.x) .trim(); const pos = cursor.coordinate; const move = cursor.shift(-1); const cell = cursor.getCell(); this._appliedAt = cell ? { isLastChar, pos, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars(), } : { isLastChar, pos, oldAttributes: "", oldChar: "" }; return move + VT.DeleteChar; } rollback(cursor: Cursor) { if (!this._appliedAt) { return ""; // not applied } const { oldAttributes, oldChar, pos } = this._appliedAt; if (!oldChar) { return cursor.moveTo(pos) + VT.DeleteChar; } return ( oldAttributes + oldChar + cursor.moveTo(pos) + attributesToSeq(core(this._terminal)._inputHandler._curAttrData) ); } rollForwards() { return ""; } matches(input: StringReader) { if (this._appliedAt?.isLastChar) { const r1 = input.eatGradually(`\b${VT.Csi}K`); if (r1 !== MatchResult.Failure) { return r1; } const r2 = input.eatGradually(`\b \b`); if (r2 !== MatchResult.Failure) { return r2; } } return MatchResult.Failure; } } class NewlinePrediction implements IPrediction { protected _prevPosition?: ICoordinate; apply(_: IBuffer, cursor: Cursor) { this._prevPosition = cursor.coordinate; cursor.move(0, cursor.y + 1); return "\r\n"; } rollback(cursor: Cursor) { return this._prevPosition ? cursor.moveTo(this._prevPosition) : ""; } rollForwards() { return ""; // does not need to rewrite } matches(input: StringReader) { return input.eatGradually("\r\n"); } } /** * Prediction when the cursor reaches the end of the line. Similar to newline * prediction, but shells handle it slightly differently. */ class LinewrapPrediction extends NewlinePrediction implements IPrediction { override apply(_: IBuffer, cursor: Cursor) { this._prevPosition = cursor.coordinate; cursor.move(0, cursor.y + 1); return " \r"; } override matches(input: StringReader) { // bash and zshell add a space which wraps in the terminal, then a CR const r = input.eatGradually(" \r"); if (r !== MatchResult.Failure) { // zshell additionally adds a clear line after wrapping to be safe -- eat it const r2 = input.eatGradually(VT.DeleteRestOfLine); return r2 === MatchResult.Buffer ? MatchResult.Buffer : r; } return input.eatGradually("\r\n"); } } class CursorMovePrediction implements IPrediction { private _applied?: { rollForward: string; prevPosition: number; prevAttrs: string; amount: number; }; constructor( private readonly _direction: CursorMoveDirection, private readonly _moveByWords: boolean, private readonly _amount: number, ) {} apply(buffer: IBuffer, cursor: Cursor) { const prevPosition = cursor.x; const currentCell = cursor.getCell(); const prevAttrs = currentCell ? attributesToSeq(currentCell) : ""; const { _amount: amount, _direction: direction, _moveByWords: moveByWords, } = this; const delta = direction === CursorMoveDirection.Back ? -1 : 1; const target = cursor.clone(); if (moveByWords) { for (let i = 0; i < amount; i++) { moveToWordBoundary(buffer, target, delta); } } else { target.shift(delta * amount); } this._applied = { amount: Math.abs(cursor.x - target.x), prevPosition, prevAttrs, rollForward: cursor.moveTo(target), }; return this._applied.rollForward; } rollback(cursor: Cursor) { if (!this._applied) { return ""; } return ( cursor.move(this._applied.prevPosition, cursor.y) + this._applied.prevAttrs ); } rollForwards() { return ""; // does not need to rewrite } matches(input: StringReader) { if (!this._applied) { return MatchResult.Failure; } const direction = this._direction; const { amount, rollForward } = this._applied; // arg can be omitted to move one character. We don't eatGradually() here // or below moves that don't go as far as the cursor would be buffered // indefinitely if (input.eatStr(`${VT.Csi}${direction}`.repeat(amount))) { return MatchResult.Success; } // \b is the equivalent to moving one character back if (direction === CursorMoveDirection.Back) { if (input.eatStr(`\b`.repeat(amount))) { return MatchResult.Success; } } // check if the cursor position is set absolutely if (rollForward) { const r = input.eatGradually(rollForward); if (r !== MatchResult.Failure) { return r; } } // check for a relative move in the direction return input.eatGradually(`${VT.Csi}${amount}${direction}`); } } export class PredictionStats extends Disposable { private readonly _stats: [latency: number, correct: boolean][] = []; private _index = 0; private readonly _addedAtTime = new WeakMap(); private readonly _changeEmitter = new Emitter(); readonly onChange = this._changeEmitter.event; /** * Gets the percent (0-1) of predictions that were accurate. */ get accuracy() { let correctCount = 0; for (const [, correct] of this._stats) { if (correct) { correctCount++; } } return correctCount / (this._stats.length || 1); } /** * Gets the number of recorded stats. */ get sampleSize() { return this._stats.length; } /** * Gets latency stats of successful predictions. */ get latency() { const latencies = this._stats .filter(([, correct]) => correct) .map(([s]) => s) .sort(); return { count: latencies.length, min: latencies[0], median: latencies[Math.floor(latencies.length / 2)], max: latencies[latencies.length - 1], }; } /** * Gets the maximum observed latency. */ get maxLatency() { let max = -Infinity; for (const [latency, correct] of this._stats) { if (correct) { max = Math.max(latency, max); } } return max; } constructor(timeline: PredictionTimeline) { super(); this._register( timeline.onPredictionAdded((p) => this._addedAtTime.set(p, Date.now())), ); this._register( timeline.onPredictionSucceeded(this._pushStat.bind(this, true)), ); this._register( timeline.onPredictionFailed(this._pushStat.bind(this, false)), ); } private _pushStat(correct: boolean, prediction: IPrediction) { const started = this._addedAtTime.get(prediction)!; this._stats[this._index] = [Date.now() - started, correct]; this._index = (this._index + 1) % StatsConstants.StatsBufferSize; this._changeEmitter.fire(); } } export class PredictionTimeline { /** * Expected queue of events. Only predictions for the lowest are * written into the terminal. */ private _expected: { gen: number; p: IPrediction }[] = []; /** * Current prediction generation. */ private _currentGen = 0; /** * Current cursor position -- kept outside the buffer since it can be ahead * if typing swiftly. The position of the cursor that the user is currently * looking at on their screen (or will be looking at after all pending writes * are flushed.) */ private _physicalCursor: Cursor | undefined; /** * Cursor position taking into account all (possibly not-yet-applied) * predictions. A new prediction inserted, if applied, will be applied at * the position of the tentative cursor. */ private _tenativeCursor: Cursor | undefined; /** * Previously sent data that was buffered and should be prepended to the * next input. */ private _inputBuffer?: string; /** * Whether predictions are echoed to the terminal. If false, predictions * will still be computed internally for latency metrics, but input will * never be adjusted. */ private _showPredictions = false; /** * The last successfully-made prediction. */ private _lookBehind?: IPrediction; private readonly _addedEmitter = new Emitter(); readonly onPredictionAdded = this._addedEmitter.event; private readonly _failedEmitter = new Emitter(); readonly onPredictionFailed = this._failedEmitter.event; private readonly _succeededEmitter = new Emitter(); readonly onPredictionSucceeded = this._succeededEmitter.event; private get _currentGenerationPredictions() { return this._expected .filter(({ gen }) => gen === this._expected[0].gen) .map(({ p }) => p); } get isShowingPredictions() { return this._showPredictions; } get length() { return this._expected.length; } constructor( readonly terminal: Terminal, private readonly _style: TypeAheadStyle, ) {} setShowPredictions(show: boolean) { if (show === this._showPredictions) { return; } // console.log('set predictions:', show); this._showPredictions = show; const buffer = this._getActiveBuffer(); if (!buffer) { return; } const toApply = this._currentGenerationPredictions; if (show) { this.clearCursor(); this._style.expectIncomingStyle( toApply.reduce((count, p) => (p.affectsStyle ? count + 1 : count), 0), ); this.terminal.write( toApply .map((p) => p.apply(buffer, this.physicalCursor(buffer))) .join(""), ); } else { this.terminal.write( toApply .reverse() .map((p) => p.rollback(this.physicalCursor(buffer))) .join(""), ); } } /** * Undoes any predictions written and resets expectations. */ undoAllPredictions() { const buffer = this._getActiveBuffer(); if (this._showPredictions && buffer) { this.terminal.write( this._currentGenerationPredictions .reverse() .map((p) => p.rollback(this.physicalCursor(buffer))) .join(""), ); } this._expected = []; } /** * Should be called when input is incoming to the temrinal. */ beforeServerInput(input: string): string { const originalInput = input; if (this._inputBuffer) { input = this._inputBuffer + input; this._inputBuffer = undefined; } if (!this._expected.length) { this._clearPredictionState(); return input; } const buffer = this._getActiveBuffer(); if (!buffer) { this._clearPredictionState(); return input; } let output = ""; const reader = new StringReader(input); const startingGen = this._expected[0].gen; const emitPredictionOmitted = () => { const omit = reader.eatRe(PREDICTION_OMIT_RE); if (omit) { output += omit[0]; } }; ReadLoop: while (this._expected.length && reader.remaining > 0) { emitPredictionOmitted(); const { p: prediction, gen } = this._expected[0]; const cursor = this.physicalCursor(buffer); const beforeTestReaderIndex = reader.index; switch (prediction.matches(reader, this._lookBehind)) { case MatchResult.Success: { // if the input character matches what the next prediction expected, undo // the prediction and write the real character out. const eaten = input.slice(beforeTestReaderIndex, reader.index); if (gen === startingGen) { output += prediction.rollForwards?.(cursor, eaten); } else { prediction.apply(buffer, this.physicalCursor(buffer)); // move cursor for additional apply output += eaten; } this._succeededEmitter.fire(prediction); this._lookBehind = prediction; this._expected.shift(); break; } case MatchResult.Buffer: // on a buffer, store the remaining data and completely read data // to be output as normal. this._inputBuffer = input.slice(beforeTestReaderIndex); reader.index = input.length; break ReadLoop; case MatchResult.Failure: { // on a failure, roll back all remaining items in this generation // and clear predictions, since they are no longer valid const rollback = this._expected .filter((p) => p.gen === startingGen) .reverse(); output += rollback .map(({ p }) => p.rollback(this.physicalCursor(buffer))) .join(""); if (rollback.some((r) => r.p.affectsStyle)) { // reading the current style should generally be safe, since predictions // always restore the style if they modify it. output += attributesToSeq( core(this.terminal)._inputHandler._curAttrData, ); } this._clearPredictionState(); this._failedEmitter.fire(prediction); break ReadLoop; } } } emitPredictionOmitted(); // Extra data (like the result of running a command) should cause us to // reset the cursor if (!reader.eof) { output += reader.rest; this._clearPredictionState(); } // If we passed a generation boundary, apply the current generation's predictions if (this._expected.length && startingGen !== this._expected[0].gen) { for (const { p, gen } of this._expected) { if (gen !== this._expected[0].gen) { break; } if (p.affectsStyle) { this._style.expectIncomingStyle(); } output += p.apply(buffer, this.physicalCursor(buffer)); } } if (!this._showPredictions) { return originalInput; } if (output.length === 0 || output === input) { return output; } if (this._physicalCursor) { output += this._physicalCursor.moveInstruction(); } // prevent cursor flickering while typing output = VT.HideCursor + output + VT.ShowCursor; return output; } /** * Clears any expected predictions and stored state. Should be called when * the pty gives us something we don't recognize. */ private _clearPredictionState() { this._expected = []; this.clearCursor(); this._lookBehind = undefined; } /** * Appends a typeahead prediction. */ addPrediction(buffer: IBuffer, prediction: IPrediction) { this._expected.push({ gen: this._currentGen, p: prediction }); this._addedEmitter.fire(prediction); if (this._currentGen !== this._expected[0].gen) { prediction.apply(buffer, this.tentativeCursor(buffer)); return false; } const text = prediction.apply(buffer, this.physicalCursor(buffer)); this._tenativeCursor = undefined; // next read will get or clone the physical cursor if (this._showPredictions && text) { if (prediction.affectsStyle) { this._style.expectIncomingStyle(); } // console.log('predict:', JSON.stringify(text)); this.terminal.write(text); } return true; } /** * Appends a prediction followed by a boundary. The predictions applied * after this one will only be displayed after the give prediction matches * pty output/ */ addBoundary(): void; addBoundary(buffer: IBuffer, prediction: IPrediction): boolean; addBoundary(buffer?: IBuffer, prediction?: IPrediction) { let applied = false; if (buffer && prediction) { // We apply the prediction so that it's matched against, but wrapped // in a tentativeboundary so that it doesn't affect the physical cursor. // Then we apply it specifically to the tentative cursor. applied = this.addPrediction(buffer, new TentativeBoundary(prediction)); prediction.apply(buffer, this.tentativeCursor(buffer)); } this._currentGen++; return applied; } /** * Peeks the last prediction written. */ peekEnd(): IPrediction | undefined { return this._expected[this._expected.length - 1]?.p; } /** * Peeks the first pending prediction. */ peekStart(): IPrediction | undefined { return this._expected[0]?.p; } /** * Current position of the cursor in the terminal. */ physicalCursor(buffer: IBuffer) { if (!this._physicalCursor) { if (this._showPredictions) { flushOutput(this.terminal); } this._physicalCursor = new Cursor( this.terminal.rows, this.terminal.cols, buffer, ); } return this._physicalCursor; } /** * Cursor position if all predictions and boundaries that have been inserted * so far turn out to be successfully predicted. */ tentativeCursor(buffer: IBuffer) { if (!this._tenativeCursor) { this._tenativeCursor = this.physicalCursor(buffer).clone(); } return this._tenativeCursor; } clearCursor() { this._physicalCursor = undefined; this._tenativeCursor = undefined; } private _getActiveBuffer() { const buffer = this.terminal.buffer.active; return buffer.type === "normal" ? buffer : undefined; } } /** * Gets the escape sequence args to restore state/appearance in the cell. */ const attributesToArgs = (cell: any) => { // cell: XtermAttributes if (cell.isAttributeDefault()) { return [0]; } const args = []; if (cell.isBold()) { args.push(1); } if (cell.isDim()) { args.push(2); } if (cell.isItalic()) { args.push(3); } if (cell.isUnderline()) { args.push(4); } if (cell.isBlink()) { args.push(5); } if (cell.isInverse()) { args.push(7); } if (cell.isInvisible()) { args.push(8); } if (cell.isFgRGB()) { args.push( 38, 2, cell.getFgColor() >>> 24, (cell.getFgColor() >>> 16) & 0xff, cell.getFgColor() & 0xff, ); } if (cell.isFgPalette()) { args.push(38, 5, cell.getFgColor()); } if (cell.isFgDefault()) { args.push(39); } if (cell.isBgRGB()) { args.push( 48, 2, cell.getBgColor() >>> 24, (cell.getBgColor() >>> 16) & 0xff, cell.getBgColor() & 0xff, ); } if (cell.isBgPalette()) { args.push(48, 5, cell.getBgColor()); } if (cell.isBgDefault()) { args.push(49); } return args; }; /** * Gets the escape sequence to restore state/appearance in the cell. */ const attributesToSeq = (cell: any) => `${VT.Csi}${attributesToArgs(cell).join(";")}m`; // cell: XtermAttributes const arrayHasPrefixAt = ( a: ReadonlyArray, ai: number, b: ReadonlyArray, ) => { if (a.length - ai > b.length) { return false; } for (let bi = 0; bi < b.length; bi++, ai++) { if (b[ai] !== a[ai]) { return false; } } return true; }; /** * @see https://github.com/xtermjs/xterm.js/blob/065eb13a9d3145bea687239680ec9696d9112b8e/src/common/InputHandler.ts#L2127 */ const getColorWidth = (params: (number | number[])[], pos: number) => { const accu = [0, 0, -1, 0, 0, 0]; let cSpace = 0; let advance = 0; do { const v = params[pos + advance]; accu[advance + cSpace] = typeof v === "number" ? v : v[0]; if (typeof v !== "number") { let i = 0; do { if (accu[1] === 5) { cSpace = 1; } accu[advance + i + 1 + cSpace] = v[i]; } while (++i < v.length && i + advance + 1 + cSpace < accu.length); break; } // exit early if can decide color mode with semicolons if ( (accu[1] === 5 && advance + cSpace >= 2) || (accu[1] === 2 && advance + cSpace >= 5) ) { break; } // offset colorSpace slot for semicolon mode if (accu[1]) { cSpace = 1; } } while (++advance + pos < params.length && advance + cSpace < accu.length); return advance; }; class TypeAheadStyle implements IDisposable { private static _compileArgs(args: ReadonlyArray) { return `${VT.Csi}${args.join(";")}m`; } /** * Number of typeahead style arguments we expect to read. If this is 0 and * we see a style coming in, we know that the PTY actually wanted to update. */ private _expectedIncomingStyles = 0; private _applyArgs!: ReadonlyArray; private _originalUndoArgs!: ReadonlyArray; private _undoArgs!: ReadonlyArray; apply!: string; undo!: string; private _csiHandler?: IDisposable; constructor(value: string, private readonly _terminal: Terminal) { this.onUpdate(value); } /** * Signals that a style was written to the terminal and we should watch * for it coming in. */ expectIncomingStyle(n = 1) { this._expectedIncomingStyles += n * 2; } /** * Starts tracking for CSI changes in the terminal. */ startTracking() { this._expectedIncomingStyles = 0; this._onDidWriteSGR( attributesToArgs(core(this._terminal)._inputHandler._curAttrData), ); this._csiHandler = this._terminal.parser.registerCsiHandler( { final: "m" }, (args) => { this._onDidWriteSGR(args); return false; }, ); } /** * Stops tracking terminal CSI changes. */ @debounce(2000) debounceStopTracking() { this._stopTracking(); } /** * @inheritdoc */ dispose() { this._stopTracking(); } private _stopTracking() { this._csiHandler?.dispose(); this._csiHandler = undefined; } private _onDidWriteSGR(args: (number | number[])[]) { const originalUndo = this._undoArgs; for (let i = 0; i < args.length; ) { const px = args[i]; const p = typeof px === "number" ? px : px[0]; if (this._expectedIncomingStyles) { if (arrayHasPrefixAt(args, i, this._undoArgs)) { this._expectedIncomingStyles--; i += this._undoArgs.length; continue; } if (arrayHasPrefixAt(args, i, this._applyArgs)) { this._expectedIncomingStyles--; i += this._applyArgs.length; continue; } } const width = p === 38 || p === 48 || p === 58 ? getColorWidth(args, i) : 1; switch (this._applyArgs[0]) { case 1: if (p === 2) { this._undoArgs = [22, 2]; } else if (p === 22 || p === 0) { this._undoArgs = [22]; } break; case 2: if (p === 1) { this._undoArgs = [22, 1]; } else if (p === 22 || p === 0) { this._undoArgs = [22]; } break; case 38: if (p === 0 || p === 39 || p === 100) { this._undoArgs = [39]; } else if ((p >= 30 && p <= 38) || (p >= 90 && p <= 97)) { this._undoArgs = args.slice(i, i + width) as number[]; } break; default: if (p === this._applyArgs[0]) { this._undoArgs = this._applyArgs; } else if (p === 0) { this._undoArgs = this._originalUndoArgs; } // no-op } i += width; } if (originalUndo !== this._undoArgs) { this.undo = TypeAheadStyle._compileArgs(this._undoArgs); } } /** * Updates the current typeahead style. */ onUpdate(style: string) { const { applyArgs, undoArgs } = this._getArgs(style); this._applyArgs = applyArgs; this._undoArgs = this._originalUndoArgs = undoArgs; this.apply = TypeAheadStyle._compileArgs(this._applyArgs); this.undo = TypeAheadStyle._compileArgs(this._undoArgs); } private _getArgs(style: string) { switch (style) { case "bold": return { applyArgs: [1], undoArgs: [22] }; case "dim": return { applyArgs: [2], undoArgs: [22] }; case "italic": return { applyArgs: [3], undoArgs: [23] }; case "underlined": return { applyArgs: [4], undoArgs: [24] }; case "inverted": return { applyArgs: [7], undoArgs: [27] }; default: { // NOTE(ekzhang): This originally used `vs/base/common/color.ts`, and I reimplemented it. let r: number, g: number, b: number; try { const parseHexColor = (style: string): number[] => { const matches = style.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); if (!matches) { throw new Error("Invalid color"); } const hex = matches[1]; if (hex.length === 3) { return [ parseInt(hex.charAt(0), 16) * 17, parseInt(hex.charAt(1), 16) * 17, parseInt(hex.charAt(2), 16) * 17, ]; } return [ parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16), ]; }; [r, g, b] = parseHexColor(style); } catch { [r, g, b] = [255, 0, 0]; } return { applyArgs: [38, 2, r, g, b], undoArgs: [39] }; } } } } const compileExcludeRegexp = (programs: ReadonlyArray) => new RegExp(`\\b(${programs.map(escapeRegExpCharacters).join("|")})\\b`, "i"); export const enum CharPredictState { /** No characters typed on this line yet */ Unknown, /** Has a pending character prediction */ HasPendingChar, /** Character validated on this line */ Validated, } export class TypeAheadAddon extends Disposable implements ITerminalAddon { private _typeaheadStyle?: TypeAheadStyle; private _typeaheadThreshold = 50; // ITerminalConfiguration.localEchoLatencyThreshold private _excludeProgramRe = compileExcludeRegexp([ "vim", "vi", "nano", "tmux", "nvim", "mprocs", ]); // ITerminalConfiguration.localEchoExcludePrograms, protected _lastRow?: { y: number; startingX: number; endingX: number; charState: CharPredictState; }; protected _timeline?: PredictionTimeline; private _terminalTitle = ""; stats?: PredictionStats; /** * Debounce that clears predictions after a timeout if the PTY doesn't apply them. */ private _clearPredictionDebounce?: IDisposable; constructor() { // private _processManager: ITerminalProcessManager, super(); this._register( toDisposable(() => this._clearPredictionDebounce?.dispose()), ); } activate(terminal: Terminal): void { const style = (this._typeaheadStyle = this._register( new TypeAheadStyle( "dim", // ITerminalConfiguration.localEchoStyle terminal, ), )); const timeline = (this._timeline = new PredictionTimeline( terminal, this._typeaheadStyle!, )); const stats = (this.stats = this._register( new PredictionStats(this._timeline), )); timeline.setShowPredictions(this._typeaheadThreshold === 0); this._register(terminal.onData((e) => this._onUserData(e))); this._register( terminal.onTitleChange((title) => { this._terminalTitle = title; this._reevaluatePredictorState(stats, timeline); }), ); this._register( terminal.onResize(() => { timeline.setShowPredictions(false); timeline.clearCursor(); this._reevaluatePredictorState(stats, timeline); }), ); this._register( this._timeline.onPredictionSucceeded((p) => { if ( this._lastRow?.charState === CharPredictState.HasPendingChar && isTenativeCharacterPrediction(p) && p.inner.appliedAt ) { if ( p.inner.appliedAt.pos.y + p.inner.appliedAt.pos.baseY === this._lastRow.y ) { this._lastRow.charState = CharPredictState.Validated; } } }), ); // this._register( // this._processManager.onBeforeProcessData((e) => // this._onBeforeProcessData(e), // ), // ); let nextStatsSend: any; this._register( stats.onChange(() => { if (!nextStatsSend) { nextStatsSend = setTimeout(() => { this._sendLatencyStats(stats); nextStatsSend = undefined; }, StatsConstants.StatsSendTelemetryEvery); } if (timeline.length === 0) { style.debounceStopTracking(); } this._reevaluatePredictorState(stats, timeline); }), ); } reset() { this._lastRow = undefined; } private _deferClearingPredictions() { if (!this.stats || !this._timeline) { return; } this._clearPredictionDebounce?.dispose(); if ( this._timeline.length === 0 || this._timeline.peekStart()?.clearAfterTimeout === false ) { this._clearPredictionDebounce = undefined; return; } this._clearPredictionDebounce = disposableTimeout(() => { this._timeline?.undoAllPredictions(); if (this._lastRow?.charState === CharPredictState.HasPendingChar) { this._lastRow.charState = CharPredictState.Unknown; } }, Math.max(500, (this.stats.maxLatency * 3) / 2)); } /** * Note on debounce: * * We want to toggle the state only when the user has a pause in their * typing. Otherwise, we could turn this on when the PTY sent data but the * terminal cursor is not updated, causes issues. */ @debounce(100) protected _reevaluatePredictorState( stats: PredictionStats, timeline: PredictionTimeline, ) { this._reevaluatePredictorStateNow(stats, timeline); } protected _reevaluatePredictorStateNow( stats: PredictionStats, timeline: PredictionTimeline, ) { if (this._excludeProgramRe.test(this._terminalTitle)) { timeline.setShowPredictions(false); } else if (this._typeaheadThreshold < 0) { timeline.setShowPredictions(false); } else if (this._typeaheadThreshold === 0) { timeline.setShowPredictions(true); } else if ( stats.sampleSize > StatsConstants.StatsMinSamplesToTurnOn && stats.accuracy > StatsConstants.StatsMinAccuracyToTurnOn ) { const latency = stats.latency.median; if (latency >= this._typeaheadThreshold) { timeline.setShowPredictions(true); } else if ( latency < this._typeaheadThreshold / StatsConstants.StatsToggleOffThreshold ) { timeline.setShowPredictions(false); } } } private _sendLatencyStats(stats: PredictionStats) { /* __GDPR__ "terminalLatencyStats" : { "owner": "Tyriar", "min" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "max" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "median" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "count" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "predictionAccuracy" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true } } */ // this._telemetryService.publicLog("terminalLatencyStats", { // ...stats.latency, // predictionAccuracy: stats.accuracy, // }); void stats; } private _onUserData(data: string): void { if (this._timeline?.terminal.buffer.active.type !== "normal") { return; } // console.log('user data:', JSON.stringify(data)); const terminal = this._timeline.terminal; const buffer = terminal.buffer.active; // Detect programs like git log/less that use the normal buffer but don't // take input by deafult (fixes #109541) if (buffer.cursorX === 1 && buffer.cursorY === terminal.rows - 1) { if ( buffer .getLine(buffer.cursorY + buffer.baseY) ?.getCell(0) ?.getChars() === ":" ) { return; } } // the following code guards the terminal prompt to avoid being able to // arrow or backspace-into the prompt. Record the lowest X value at which // the user gave input, and mark all additions before that as tentative. const actualY = buffer.baseY + buffer.cursorY; if (actualY !== this._lastRow?.y) { this._lastRow = { y: actualY, startingX: buffer.cursorX, endingX: buffer.cursorX, charState: CharPredictState.Unknown, }; } else { this._lastRow.startingX = Math.min( this._lastRow.startingX, buffer.cursorX, ); this._lastRow.endingX = Math.max( this._lastRow.endingX, this._timeline.physicalCursor(buffer).x, ); } const addLeftNavigating = (p: IPrediction) => this._timeline!.tentativeCursor(buffer).x <= this._lastRow!.startingX ? this._timeline!.addBoundary(buffer, p) : this._timeline!.addPrediction(buffer, p); const addRightNavigating = (p: IPrediction) => this._timeline!.tentativeCursor(buffer).x >= this._lastRow!.endingX - 1 ? this._timeline!.addBoundary(buffer, p) : this._timeline!.addPrediction(buffer, p); /** @see https://github.com/xtermjs/xterm.js/blob/1913e9512c048e3cf56bb5f5df51bfff6899c184/src/common/input/Keyboard.ts */ const reader = new StringReader(data); while (reader.remaining > 0) { if (reader.eatCharCode(127)) { // backspace const previous = this._timeline.peekEnd(); if (previous && previous instanceof CharacterPrediction) { this._timeline.addBoundary(); } // backspace must be able to read the previously-written character in // the event that it needs to undo it if (this._timeline.isShowingPredictions) { flushOutput(this._timeline.terminal); } if ( this._timeline.tentativeCursor(buffer).x <= this._lastRow!.startingX ) { this._timeline.addBoundary( buffer, new BackspacePrediction(this._timeline.terminal), ); } else { // Backspace decrements our ability to go right. this._lastRow.endingX--; this._timeline!.addPrediction( buffer, new BackspacePrediction(this._timeline.terminal), ); } continue; } if (reader.eatCharCode(32, 126)) { // alphanum const char = data[reader.index - 1]; const prediction = new CharacterPrediction(this._typeaheadStyle!, char); if (this._lastRow.charState === CharPredictState.Unknown) { this._timeline.addBoundary(buffer, prediction); this._lastRow.charState = CharPredictState.HasPendingChar; } else { this._timeline.addPrediction(buffer, prediction); } if (this._timeline.tentativeCursor(buffer).x >= terminal.cols) { this._timeline.addBoundary(buffer, new LinewrapPrediction()); } continue; } const cursorMv = reader.eatRe(CSI_MOVE_RE); if (cursorMv) { const direction = cursorMv[3] as CursorMoveDirection; const p = new CursorMovePrediction( direction, !!cursorMv[2], Number(cursorMv[1]) || 1, ); if (direction === CursorMoveDirection.Back) { addLeftNavigating(p); } else { addRightNavigating(p); } continue; } if (reader.eatStr(`${VT.Esc}f`)) { addRightNavigating( new CursorMovePrediction(CursorMoveDirection.Forwards, true, 1), ); continue; } if (reader.eatStr(`${VT.Esc}b`)) { addLeftNavigating( new CursorMovePrediction(CursorMoveDirection.Back, true, 1), ); continue; } if (reader.eatChar("\r") && buffer.cursorY < terminal.rows - 1) { this._timeline.addPrediction(buffer, new NewlinePrediction()); continue; } // something else this._timeline.addBoundary(buffer, new HardBoundary()); break; } if (this._timeline.length === 1) { this._deferClearingPredictions(); this._typeaheadStyle!.startTracking(); } } // private _onBeforeProcessData(event: IBeforeProcessDataEvent): void { // if (!this._timeline) { // return; // } // // console.log('incoming data:', JSON.stringify(event.data)); // event.data = this._timeline.beforeServerInput(event.data); // // console.log('emitted data:', JSON.stringify(event.data)); // this._deferClearingPredictions(); // } onBeforeProcessData(data: string): string { if (!this._timeline) return data; // console.log("incoming data:", JSON.stringify(data)); data = this._timeline.beforeServerInput(data); // console.log("emitted data:", JSON.stringify(data)); this._deferClearingPredictions(); return data; } } ================================================ FILE: src/lib/ui/Avatars.svelte ================================================
{#each users as [id, user] (id)}
{nameToInitials(user.name)}
{/each}
================================================ FILE: src/lib/ui/Chat.svelte ================================================
dispatch("close")} />
Chat Messages
{#each groupedMessages as chatGroup}
{#each chatGroup as chat (chat)}
{chat.msg}
{/each}
{/each}
{#if text} {/if}
================================================ FILE: src/lib/ui/ChooseName.svelte ================================================
================================================ FILE: src/lib/ui/CircleButton.svelte ================================================ ================================================ FILE: src/lib/ui/CircleButtons.svelte ================================================
================================================ FILE: src/lib/ui/CopyableCode.svelte ================================================
{value}
================================================ FILE: src/lib/ui/DownloadLink.svelte ================================================ ================================================ FILE: src/lib/ui/LiveCursor.svelte ================================================
(hovering = true)} on:mouseleave={() => (hovering = false)} > {#if showName || hovering || time - lastMove < 1500}

{user.name}

{/if}
================================================ FILE: src/lib/ui/NameList.svelte ================================================
    {#each sortedUsers as [id, user] (id)}
  • {user.name}
  • {/each}
================================================ FILE: src/lib/ui/NetworkInfo.svelte ================================================

Network

{#if status === "connected"} {#if serverLatency === null || shellLatency === null} Connected, estimating latency… {:else} Total latency: {displayLatency(serverLatency + shellLatency)} {/if} {:else} You are currently disconnected. {/if}

You

{#if status === "connected"}

{#if serverLatency !== null} ~{displayLatency(serverLatency)} {/if}

{/if}

Server

{#if status === "connected"}

{#if shellLatency !== null} ~{displayLatency(shellLatency)} {/if}

{/if}

Shell

================================================ FILE: src/lib/ui/OverlayMenu.svelte ================================================
{#if showCloseButton} {/if}
{title} {description}
================================================ FILE: src/lib/ui/Settings.svelte ================================================

Name

Choose how you appear to other users.

{ if (inputName.length >= 2) { updateSettings({ name: inputName }); } }} />

Color palette

Color theme for text in terminals.

Scrollback

Lines of previous text displayed in the terminal window.

{ if (inputScrollback >= 0) { updateSettings({ scrollback: inputScrollback }); } }} step="100" />

sshx-server v{__APP_VERSION__}

================================================ FILE: src/lib/ui/TeaserVideo.svelte ================================================
sshx logo

sshx

sshx.io/s/gzN0WHsm6r#tiOAVOLsNXEZxJ

================================================ FILE: src/lib/ui/Toast.svelte ================================================
{#if kind === "info"} {:else if kind === "success"} {:else if kind === "error"} {:else} {/if}

{message}

{#if action}
{/if}
================================================ FILE: src/lib/ui/ToastContainer.svelte ================================================
{#each $toastStore.slice().reverse() as toast (toast)}
($toastStore = $toastStore.filter((t) => t !== toast))} on:keypress={() => null} animate:flip={{ duration: 500 }} transition:fly={{ x: 360, duration: 500 }} > null)} />
{/each}
================================================ FILE: src/lib/ui/Toolbar.svelte ================================================
sshx logo

sshx

================================================ FILE: src/lib/ui/XTerm.svelte ================================================
dispatch("bringToFront")} on:pointerdown={(event) => event.stopPropagation()} >
dispatch("startMove", event)} >
event.button === 0 && dispatch("close")} /> event.button === 0 && dispatch("shrink")} /> event.button === 0 && dispatch("expand")} />
{currentTitle}
{ if (focused) { // Don't pan the page when scrolling while the terminal is selected. // Conversely, we manually disable terminal scrolling unless it is currently selected. event.stopPropagation(); } }} />
================================================ FILE: src/lib/ui/themes.ts ================================================ import type { ITheme } from "sshx-xterm"; /** VSCode default dark theme, from https://glitchbone.github.io/vscode-base16-term/. */ const defaultDark: ITheme = { foreground: "#d8d8d8", background: "#181818", cursor: "#d8d8d8", black: "#181818", red: "#ab4642", green: "#a1b56c", yellow: "#f7ca88", blue: "#7cafc2", magenta: "#ba8baf", cyan: "#86c1b9", white: "#d8d8d8", brightBlack: "#585858", brightRed: "#ab4642", brightGreen: "#a1b56c", brightYellow: "#f7ca88", brightBlue: "#7cafc2", brightMagenta: "#ba8baf", brightCyan: "#86c1b9", brightWhite: "#f8f8f8", }; /** Hybrid theme from https://terminal.sexy/, using Alacritty export format. */ const hybrid: ITheme = { foreground: "#c5c8c6", background: "#1d1f21", black: "#282a2e", red: "#a54242", green: "#8c9440", yellow: "#de935f", blue: "#5f819d", magenta: "#85678f", cyan: "#5e8d87", white: "#707880", brightBlack: "#373b41", brightRed: "#cc6666", brightGreen: "#b5bd68", brightYellow: "#f0c674", brightBlue: "#81a2be", brightMagenta: "#b294bb", brightCyan: "#8abeb7", brightWhite: "#c5c8c6", }; /** Below themes are converted from https://github.com/alacritty/alacritty-theme/. */ const rosePine: ITheme = { foreground: "#e0def4", background: "#191724", cursor: "#524f67", black: "#26233a", red: "#eb6f92", green: "#31748f", yellow: "#f6c177", blue: "#9ccfd8", magenta: "#c4a7e7", cyan: "#ebbcba", white: "#e0def4", brightBlack: "#6e6a86", brightRed: "#eb6f92", brightGreen: "#31748f", brightYellow: "#f6c177", brightBlue: "#9ccfd8", brightMagenta: "#c4a7e7", brightCyan: "#ebbcba", brightWhite: "#e0def4", }; const ubuntu: ITheme = { foreground: "#eeeeec", background: "#300a24", black: "#2e3436", red: "#cc0000", green: "#4e9a06", yellow: "#c4a000", blue: "#3465a4", magenta: "#75507b", cyan: "#06989a", white: "#d3d7cf", brightBlack: "#555753", brightRed: "#ef2929", brightGreen: "#8ae234", brightYellow: "#fce94f", brightBlue: "#729fcf", brightMagenta: "#ad7fa8", brightCyan: "#34e2e2", brightWhite: "#eeeeec", }; const dracula: ITheme = { foreground: "#f8f8f2", background: "#282a36", black: "#000000", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c", blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#bbbbbb", brightBlack: "#555555", brightRed: "#ff5555", brightGreen: "#50fa7b", brightYellow: "#f1fa8c", brightBlue: "#caa9fa", brightMagenta: "#ff79c6", brightCyan: "#8be9fd", brightWhite: "#ffffff", }; const githubDark: ITheme = { foreground: "#d1d5da", background: "#24292e", black: "#586069", red: "#ea4a5a", green: "#34d058", yellow: "#ffea7f", blue: "#2188ff", magenta: "#b392f0", cyan: "#39c5cf", white: "#d1d5da", brightBlack: "#959da5", brightRed: "#f97583", brightGreen: "#85e89d", brightYellow: "#ffea7f", brightBlue: "#79b8ff", brightMagenta: "#b392f0", brightCyan: "#56d4dd", brightWhite: "#fafbfc", }; const gruvboxDark: ITheme = { foreground: "#ebdbb2", background: "#282828", black: "#282828", red: "#cc241d", green: "#98971a", yellow: "#d79921", blue: "#458588", magenta: "#b16286", cyan: "#689d6a", white: "#a89984", brightBlack: "#928374", brightRed: "#fb4934", brightGreen: "#b8bb26", brightYellow: "#fabd2f", brightBlue: "#83a598", brightMagenta: "#d3869b", brightCyan: "#8ec07c", brightWhite: "#ebdbb2", }; const solarizedDark: ITheme = { foreground: "#839496", background: "#002b36", black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900", blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5", brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75", brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4", brightCyan: "#93a1a1", brightWhite: "#fdf6e3", }; const tokyoNight: ITheme = { foreground: "#a9b1d6", background: "#1a1b26", black: "#32344a", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68", blue: "#7aa2f7", magenta: "#ad8ee6", cyan: "#449dab", white: "#787c99", brightBlack: "#444b6a", brightRed: "#ff7a93", brightGreen: "#b9f27c", brightYellow: "#ff9e64", brightBlue: "#7da6ff", brightMagenta: "#bb9af7", brightCyan: "#0db9d7", brightWhite: "#acb0d0", }; const themes = { "VS Code Dark": defaultDark, Hybrid: hybrid, "Rosé Pine": rosePine, Ubuntu: ubuntu, Dracula: dracula, "GitHub Dark": githubDark, "Gruvbox Dark": gruvboxDark, "Solarized Dark": solarizedDark, "Tokyo Night": tokyoNight, }; export type ThemeName = keyof typeof themes; export const defaultTheme: ThemeName = "VS Code Dark"; export default themes; ================================================ FILE: src/routes/+error.svelte ================================================
sshx logo

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

{#if $page.status !== 404}
{JSON.stringify($page.error, null, 2)}
{/if}

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

Return home
================================================ FILE: src/routes/+layout.svelte ================================================ ================================================ FILE: src/routes/+page.svelte ================================================
sshx logo

A secure web-based, collaborative terminal

two terminal windows running sshx and three live cursors

sshx lets you share your terminal with anyone by link, on a multiplayer infinite canvas.

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

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

Collaborative

Invite people by sharing a secure, unique browser link.

End-to-end encrypted

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

Cross-platform

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

Infinite canvas

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

Live presence

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

Ultra-fast mesh networking

Connect from anywhere to the nearest distributed peer in a global network.

Installation

macOS / Linux

Run the following in your terminal:

Or, download the binary for your platform.

macOS ARM64 (Apple Silicon) macOS x86-64 (Intel)
Linux ARM64 Linux x86-64 Linux ARMv6 Linux ARMv7
FreeBSD x86-64

Windows

Download the executable for your platform.

Windows x86-64 Windows x86 Windows ARM64

Build from source

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

GitHub Actions

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


{#each socials as social} {social.title} {/each}

open source, © Eric Zhang 2023

================================================ FILE: src/routes/+page.ts ================================================ export const prerender = true; ================================================ FILE: src/routes/s/[id]/+page.svelte ================================================ {title} { if (sessionName) { title = `${sessionName} | sshx`; } }} /> ================================================ FILE: static/get ================================================ #!/bin/sh # This is a short script to install the latest version of the sshx binary. # # It's meant to be as simple as possible, so if you're not happy hardcoding a # `curl | sh` pipe in your application, you can just download the binary # directly with the appropriate URL for your architecture. # # If you'd like to run it without installing to /usr/local/bin, use `sh -s run`. # To download to the current directory, use `sh -s download`. set +e case "$(uname -s)" in Linux*) suffix="-unknown-linux-musl";; Darwin*) suffix="-apple-darwin";; FreeBSD*) suffix="-unknown-freebsd";; MINGW*|MSYS*|CYGWIN*) echo "You are on Windows. Please visit sshx.io to download the executable."; exit 1;; *) echo "Unsupported OS $(uname -s)"; exit 1;; esac case "$(uname -m)" in aarch64 | aarch64_be | arm64 | armv8b | armv8l) arch="aarch64";; x86_64 | x64 | amd64) arch="x86_64";; armv6l) arch="arm"; suffix="${suffix}eabihf";; armv7l) arch="armv7"; suffix="${suffix}eabihf";; *) echo "Unsupported arch $(uname -m)"; exit 1;; esac url="https://s3.amazonaws.com/sshx/sshx-${arch}${suffix}.tar.gz" if [ -z "$NO_COLOR" ]; then ansi_reset="\033[0m" ansi_info="\033[35;1m" ansi_error="\033[31m" ansi_underline="\033[4m" fi cmd=${1:-install} temp=$(mktemp) case $cmd in "run") path=$(mktemp -d) will_run=1 ;; "download") path=$(pwd) ;; "install") path=/usr/local/bin ;; *) printf "${ansi_error}Error: Invalid command. Please use 'run', 'download', or 'install'.\n" exit 1 ;; esac printf "${ansi_reset}${ansi_info}↯ Downloading sshx from ${ansi_underline}%s${ansi_reset}\n" "$url" http_code=$(curl "$url" -o "$temp" -w "%{http_code}") if [ "$http_code" -lt 200 ] || [ "$http_code" -gt 299 ]; then printf "${ansi_error}Error: Request had status code ${http_code}.\n" cat "$temp" 1>&2 printf "${ansi_reset}\n" exit 1 fi printf "\n${ansi_reset}${ansi_info}↯ Adding sshx binary to ${ansi_underline}%s${ansi_reset}\n" "$path" if [ "$(id -u)" -ne 0 ] && [ "$path" = "/usr/local/bin" ]; then sudo tar xf "$temp" -C "$path" || exit 1 else tar xf "$temp" -C "$path" || exit 1 fi printf "\n${ansi_reset}${ansi_info}↯ Done! You can now run sshx.${ansi_reset}\n" if [ -n "$will_run" ]; then "$path/sshx" rm -f "$path/sshx" fi ================================================ FILE: svelte.config.js ================================================ import adapter from "@sveltejs/adapter-static"; import preprocess from "svelte-preprocess"; /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://github.com/sveltejs/svelte-preprocess // for more information about preprocessors preprocess: [ preprocess({ postcss: true, }), ], kit: { adapter: adapter({ fallback: "spa.html", // SPA mode precompress: true, }), }, }; export default config; ================================================ FILE: tailwind.config.cjs ================================================ const defaultTheme = require("tailwindcss/defaultTheme"); /** @type {import("tailwindcss").Config} */ const config = { content: ["./src/**/*.{html,js,svelte,ts}"], darkMode: "class", theme: { extend: { fontFamily: { sans: ["Inter Variable", ...defaultTheme.fontFamily.sans], mono: ["Fira Code VF", ...defaultTheme.fontFamily.mono], }, }, }, plugins: [], }; module.exports = config; ================================================ FILE: tsconfig.json ================================================ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "strict": true } } ================================================ FILE: vite.config.ts ================================================ import { execSync } from "node:child_process"; import { defineConfig } from "vite"; import { sveltekit } from "@sveltejs/kit/vite"; const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); export default defineConfig({ define: { __APP_VERSION__: JSON.stringify("0.4.1-" + commitHash), }, plugins: [sveltekit()], server: { proxy: { "/api": { target: "http://[::1]:8051", changeOrigin: true, ws: true, }, }, }, });