Showing preview only (310K chars total). Download the full file or copy to clipboard to get everything.
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 <ekzhang1@gmail.com>"]
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.

**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::<Arc<[u8]>>(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<String>,
/// Channels with backpressure routing messages to each shell task.
shells_tx: HashMap<Sid, mpsc::Sender<ShellData>>,
/// Channel shared with tasks to allow them to output client messages.
output_tx: mpsc::Sender<ClientMessage>,
/// Owned receiving end of the `output_tx` channel.
output_rx: mpsc::Receiver<ClientMessage>,
}
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<Self> {
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<SshxServiceClient<Channel>, 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<ClientUpdate>, 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<aes::Aes128>;
// 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<u8> {
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<u8> {
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<String>,
/// 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<String>,
/// 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<u8>),
/// 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<ShellData>,
output_tx: mpsc::Sender<ClientMessage>,
) -> 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<ShellData>,
output_tx: mpsc::Sender<ClientMessage>,
) -> 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<ShellData>,
output_tx: mpsc::Sender<ClientMessage>,
) -> 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<Terminal> {
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<Pid> {
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<Infallible, Errno> {
// 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<io::Result<()>> {
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<io::Result<usize>> {
self.project().master_write.poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.project().master_write.poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
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<Terminal> {
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<io::Result<()>> {
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<io::Result<usize>> {
self.project().writer.poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.project().writer.poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
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<dyn std::error::Error>> {
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<uint32, uint64> 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<uint32, SerializedShell> 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<ServerState>);
impl GrpcServer {
/// Construct a new [`GrpcServer`] instance with associated state.
pub fn new(state: Arc<ServerState>) -> Self {
Self(state)
}
}
type RR<T> = Result<Response<T>, Status>;
#[tonic::async_trait]
impl SshxService for GrpcServer {
type ChannelStream = ReceiverStream<Result<ServerUpdate, Status>>;
async fn open(&self, request: Request<OpenRequest>) -> RR<OpenResponse> {
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<Streaming<ClientUpdate>>) -> RR<Self::ChannelStream> {
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<CloseRequest>) -> RR<CloseResponse> {
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<Result<ServerUpdate, Status>>;
/// Handle bidirectional streaming messages RPC messages.
async fn handle_streaming(
tx: &ServerTx,
session: &Session,
mut stream: Streaming<ClientUpdate>,
) -> 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<String>,
/// Override the origin returned for the Open() RPC.
pub override_origin: Option<String>,
/// URL of the Redis server that stores session data.
pub redis_url: Option<String>,
/// Hostname of this server, if running multiple servers.
pub host: Option<String>,
}
/// Stateful object that manages the sshx server, with graceful termination.
pub struct Server {
state: Arc<ServerState>,
shutdown: Shutdown,
}
impl Server {
/// Create a new application server, but do not listen for connections yet.
pub fn new(options: ServerOptions) -> Result<Self> {
Ok(Self {
state: Arc::new(ServerState::new(options)?),
shutdown: Shutdown::new(),
})
}
/// Returns the server's state object.
pub fn state(&self) -> Arc<ServerState> {
Arc::clone(&self.state)
}
/// Run the application server, listening on a stream of connections.
pub async fn listen<L>(&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<L>(
state: Arc<ServerState>,
listener: L,
signal: impl Future<Output = ()> + 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<Body>, _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<String>,
/// Override the origin URL returned by the Open() RPC.
#[clap(long)]
override_origin: Option<String>,
/// URL of the Redis server that stores session data.
#[clap(long, env = "SSHX_REDIS_URL")]
redis_url: Option<String>,
/// Hostname of this server, if running multiple servers.
#[clap(long)]
host: Option<String>,
}
#[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<Vec<u8>> {
let ids = self.counter.get_current_values();
let winsizes: BTreeMap<Sid, WsWinsize> = 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<Self> {
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<Bytes>,
}
/// 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<HashMap<Sid, State>>,
/// Metadata for currently connected users.
users: RwLock<HashMap<Uid, WsUser>>,
/// Atomic counter to get new, unique IDs.
counter: IdCounter,
/// Timestamp of the last backend client message from an active connection.
last_accessed: Mutex<Instant>,
/// Watch channel source for the ordered list of open shells and sizes.
source: watch::Sender<Vec<(Sid, WsWinsize)>>,
/// 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<WsServer>,
/// Sender end of a channel that buffers messages for the client.
update_tx: async_channel::Sender<ServerMessage>,
/// Receiver end of a channel that buffers messages for the client.
update_rx: async_channel::Receiver<ServerMessage>,
/// 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<Bytes>,
/// 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<Notify>,
}
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<Item = Result<WsServer, BroadcastStreamRecvError>> + Unpin {
BroadcastStream::new(self.broadcast.subscribe())
}
/// Receive a notification every time the set of shells is changed.
pub fn subscribe_shells(&self) -> impl Stream<Item = Vec<(Sid, WsWinsize)>> + 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<Item = (u64, Vec<Bytes>)> + '_ {
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::<u64>();
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<impl DerefMut<Target = State> + '_> {
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<WsWinsize>) -> 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<impl Drop + '_> {
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<ServerMessage> {
&self.update_tx
}
/// Access the receiver of the client message channel for this session.
pub fn update_rx(&self) -> &async_channel::Receiver<ServerMessage> {
&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<String>,
}
impl StorageMesh {
/// Construct a new storage object from Redis URL.
pub fn new(redis_url: &str, host: Option<&str>) -> Result<Self> {
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<Option<String>> {
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<String>, Option<Vec<u8>>)> {
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<Session>) {
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<String>,) = 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<Item = String> + 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::<String>() {
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<Sha256>,
/// Override the origin returned for the Open() RPC.
override_origin: Option<String>,
/// A concurrent map of session IDs to session objects.
store: DashMap<String, Arc<Session>>,
/// Storage and distributed communication provider, if enabled.
mesh: Option<StorageMesh>,
}
impl ServerState {
/// Create an empty server state using the given secret.
pub fn new(options: ServerOptions) -> Result<Self> {
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<Sha256> {
self.mac.clone()
}
/// Returns the override origin for the Open() RPC.
pub fn override_origin(&self) -> Option<String> {
self.override_origin.clone()
}
/// Lookup a local session by name.
pub fn lookup(&self, name: &str) -> Option<Arc<Session>> {
self.store.get(name).map(|s| s.clone())
}
/// Insert a session into the local store.
pub fn insert(&self, name: &str, session: Arc<Session>) {
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<Option<Arc<Session>>> {
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<Result<Arc<Session>, Option<String>>> {
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<Output = ()> + 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<Sid>,
/// 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<WsUser>),
/// 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<Bytes>),
/// 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<Bytes>),
/// 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<Sid>),
/// 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<WsWinsize>),
/// 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<String>,
ws: WebSocketUpgrade,
State(state): State<Arc<ServerState>>,
) -> 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<Session>) -> 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<Option<WsClient>> {
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<Bytes>)>(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<Arc<ServerState>> {
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<Arc<ServerState>> {
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<Server>,
}
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<Channel> {
SshxServiceClient::connect(self.endpoint()).await.unwrap()
}
/// Return the current server state object.
pub fn state(&self) -> Arc<ServerState> {
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<MaybeTlsStream<TcpStream>>,
encrypt: Encrypt,
write_encrypt: Option<Encrypt>,
pub user_id: Uid,
pub users: BTreeMap<Uid, WsUser>,
pub shells: BTreeMap<Sid, WsWinsize>,
pub data: HashMap<Sid, String>,
pub messages: Vec<(Uid, String, String)>,
pub errors: Vec<String>,
}
impl ClientSocket {
/// Connect to a WebSocket endpoint.
pub async fn connect(uri: &str, key: &str, write_password: Option<&str>) -> Result<Self> {
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<WsServer> {
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
================================================
/// <reference types="@sveltejs/kit" />
// 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
================================================
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no"
/>
<title>sshx</title>
<meta property="og:title" content="sshx · share collaborative terminals" />
<meta
name="description"
content="Fast, collaborative live terminals in the browser, with real-time chat, cursors, and activity tracking."
/>
<meta
property="og:description"
content="Fast, collaborative live terminals in the browser, with real-time chat, cursors, and activity tracking."
/>
<meta
property="og:image"
content="https://sshx.io/images/social-image2.png"
/>
<meta name="twitter:card" content="summary_large_image" />
%sveltekit.head%
</head>
<body class="dark:bg-[#111111] dark:text-white">
<div>%sveltekit.body%</div>
</body>
</html>
================================================
FILE: src/lib/Session.svelte
================================================
<script lang="ts">
import {
onDestroy,
onMount,
tick,
beforeUpdate,
afterUpdate,
createEventDispatcher,
} from "svelte";
import { fade } from "svelte/transition";
import { debounce, throttle } from "lodash-es";
import { Encrypt } from "./encrypt";
import { createLock } from "./lock";
import { Srocket } from "./srocket";
import type { WsClient, WsServer, WsUser, WsWinsize } from "./protocol";
import { makeToast } from "./toast";
import Chat, { type ChatMessage } from "./ui/Chat.svelte";
import ChooseName from "./ui/ChooseName.svelte";
import NameList from "./ui/NameList.svelte";
import NetworkInfo from "./ui/NetworkInfo.svelte";
import Settings from "./ui/Settings.svelte";
import Toolbar from "./ui/Toolbar.svelte";
import XTerm from "./ui/XTerm.svelte";
import Avatars from "./ui/Avatars.svelte";
import LiveCursor from "./ui/LiveCursor.svelte";
import { slide } from "./action/slide";
import { TouchZoom, INITIAL_ZOOM } from "./action/touchZoom";
import { arrangeNewTerminal } from "./arrange";
import { settings } from "./settings";
import { EyeIcon } from "svelte-feather-icons";
export let id: string;
const dispatch = createEventDispatcher<{ receiveName: string }>();
// The magic numbers "left" and "top" are used to approximately center the
// terminal at the time that it is first created.
const CONSTANT_OFFSET_LEFT = 378;
const CONSTANT_OFFSET_TOP = 240;
const OFFSET_LEFT_CSS = `calc(50vw - ${CONSTANT_OFFSET_LEFT}px)`;
const OFFSET_TOP_CSS = `calc(50vh - ${CONSTANT_OFFSET_TOP}px)`;
const OFFSET_TRANSFORM_ORIGIN_CSS = `calc(-1 * ${OFFSET_LEFT_CSS}) calc(-1 * ${OFFSET_TOP_CSS})`;
// Terminal width and height limits.
const TERM_MIN_ROWS = 8;
const TERM_MIN_COLS = 32;
function getConstantOffset() {
return [
0.5 * window.innerWidth - CONSTANT_OFFSET_LEFT,
0.5 * window.innerHeight - CONSTANT_OFFSET_TOP,
];
}
let fabricEl: HTMLElement;
let touchZoom: TouchZoom;
let center = [0, 0];
let zoom = INITIAL_ZOOM;
let showChat = false; // @hmr:keep
let settingsOpen = false; // @hmr:keep
let showNetworkInfo = false; // @hmr:keep
onMount(() => {
touchZoom = new TouchZoom(fabricEl);
touchZoom.onMove(() => {
center = touchZoom.center;
zoom = touchZoom.zoom;
// Blur if the user is currently focused on a terminal.
//
// This makes it so that panning does not stop when the cursor happens to
// intersect with the textarea, which absorbs wheel and touch events.
if (document.activeElement) {
const classList = [...document.activeElement.classList];
if (classList.includes("xterm-helper-textarea")) {
(document.activeElement as HTMLElement).blur();
}
}
showNetworkInfo = false;
});
});
/** Returns the mouse position in infinite grid coordinates, offset transformations and zoom. */
function normalizePosition(event: MouseEvent): [number, number] {
const [ox, oy] = getConstantOffset();
return [
Math.round(center[0] + event.pageX / zoom - ox),
Math.round(center[1] + event.pageY / zoom - oy),
];
}
let encrypt: Encrypt;
let srocket: Srocket<WsServer, WsClient> | null = null;
let connected = false;
let exitReason: string | null = null;
/** Bound "write" method for each terminal. */
const writers: Record<number, (data: string) => void> = {};
const termWrappers: Record<number, HTMLDivElement> = {};
const termElements: Record<number, HTMLDivElement> = {};
const chunknums: Record<number, number> = {};
const locks: Record<number, any> = {};
let userId = 0;
let users: [number, WsUser][] = [];
let shells: [number, WsWinsize][] = [];
let subscriptions = new Set<number>();
// May be undefined before `users` is first populated.
$: hasWriteAccess = users.find(([uid]) => uid === userId)?.[1]?.canWrite;
let moving = -1; // Terminal ID that is being dragged.
let movingOrigin = [0, 0]; // Coordinates of mouse at origin when drag started.
let movingSize: WsWinsize; // New [x, y] position of the dragged terminal.
let movingIsDone = false; // Moving finished but hasn't been acknowledged.
let resizing = -1; // Terminal ID that is being resized.
let resizingOrigin = [0, 0]; // Coordinates of top-left origin when resize started.
let resizingCell = [0, 0]; // Pixel dimensions of a single terminal cell.
let resizingSize: WsWinsize; // Last resize message sent.
let chatMessages: ChatMessage[] = [];
let newMessages = false;
let serverLatencies: number[] = [];
let shellLatencies: number[] = [];
onMount(async () => {
// The page hash sets the end-to-end encryption key.
const key = window.location.hash?.slice(1).split(",")[0] ?? "";
const writePassword = window.location.hash?.slice(1).split(",")[1] ?? null;
encrypt = await Encrypt.new(key);
const encryptedZeros = await encrypt.zeros();
const writeEncryptedZeros = writePassword
? await (await Encrypt.new(writePassword)).zeros()
: null;
srocket = new Srocket<WsServer, WsClient>(`/api/s/${id}`, {
onMessage(message) {
if (message.hello) {
userId = message.hello[0];
dispatch("receiveName", message.hello[1]);
makeToast({
kind: "success",
message: `Connected to the server.`,
});
exitReason = null;
} else if (message.invalidAuth) {
exitReason =
"The URL is not correct, invalid end-to-end encryption key.";
srocket?.dispose();
} else if (message.chunks) {
let [id, seqnum, chunks] = message.chunks;
locks[id](async () => {
await tick();
chunknums[id] += chunks.length;
for (const data of chunks) {
const buf = await encrypt.segment(
0x100000000n | BigInt(id),
BigInt(seqnum),
data,
);
seqnum += data.length;
writers[id](new TextDecoder().decode(buf));
}
});
} else if (message.users) {
users = message.users;
} else if (message.userDiff) {
const [id, update] = message.userDiff;
users = users.filter(([uid]) => uid !== id);
if (update !== null) {
users = [...users, [id, update]];
}
} else if (message.shells) {
shells = message.shells;
if (movingIsDone) {
moving = -1;
}
for (const [id] of message.shells) {
if (!subscriptions.has(id)) {
chunknums[id] ??= 0;
locks[id] ??= createLock();
subscriptions.add(id);
srocket?.send({ subscribe: [id, chunknums[id]] });
}
}
} else if (message.hear) {
const [uid, name, msg] = message.hear;
chatMessages.push({ uid, name, msg, sentAt: new Date() });
chatMessages = chatMessages;
if (!showChat) newMessages = true;
} else if (message.shellLatency !== undefined) {
const shellLatency = Number(message.shellLatency);
shellLatencies = [...shellLatencies, shellLatency].slice(-10);
} else if (message.pong !== undefined) {
const serverLatency = Date.now() - Number(message.pong);
serverLatencies = [...serverLatencies, serverLatency].slice(-10);
} else if (message.error) {
console.warn("Server error: " + message.error);
}
},
onConnect() {
srocket?.send({ authenticate: [encryptedZeros, writeEncryptedZeros] });
if ($settings.name) {
srocket?.send({ setName: $settings.name });
}
connected = true;
},
onDisconnect() {
connected = false;
subscriptions.clear();
users = [];
serverLatencies = [];
shellLatencies = [];
},
onClose(event) {
if (event.code === 4404) {
exitReason = "Failed to connect: " + event.reason;
} else if (event.code === 4500) {
exitReason = "Internal server error: " + event.reason;
}
},
});
});
onDestroy(() => srocket?.dispose());
// Send periodic ping messages for latency estimation.
onMount(() => {
const pingIntervalId = window.setInterval(() => {
if (srocket?.connected) {
srocket.send({ ping: BigInt(Date.now()) });
}
}, 2000);
return () => window.clearInterval(pingIntervalId);
});
function integerMedian(values: number[]) {
if (values.length === 0) {
return null;
}
const sorted = values.toSorted();
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}
$: if ($settings.name) {
srocket?.send({ setName: $settings.name });
}
let counter = 0n;
async function handleCreate() {
if (hasWriteAccess === false) {
makeToast({
kind: "info",
message: "You are in read-only mode and cannot create new terminals.",
});
return;
}
if (shells.length >= 14) {
makeToast({
kind: "error",
message: "You can only create up to 14 terminals.",
});
return;
}
const existing = shells.map(([id, winsize]) => ({
x: winsize.x,
y: winsize.y,
width: termWrappers[id].clientWidth,
height: termWrappers[id].clientHeight,
}));
const { x, y } = arrangeNewTerminal(existing);
srocket?.send({ create: [x, y] });
touchZoom.moveTo([x, y], INITIAL_ZOOM);
}
async function handleInput(id: number, data: Uint8Array) {
if (counter === 0n) {
// On the first call, initialize the counter to a random 64-bit integer.
const array = new Uint8Array(8);
crypto.getRandomValues(array);
counter = new DataView(array.buffer).getBigUint64(0);
}
const offset = counter;
counter += BigInt(data.length); // Must increment before the `await`.
const encrypted = await encrypt.segment(0x200000000n, offset, data);
srocket?.send({ data: [id, encrypted, offset] });
}
// Stupid hack to preserve input focus when terminals are reordered.
// See: https://github.com/sveltejs/svelte/issues/3973
let activeElement: Element | null = null;
beforeUpdate(() => {
activeElement = document.activeElement;
});
afterUpdate(() => {
if (activeElement instanceof HTMLElement) activeElement.focus();
});
// Global mouse handler logic follows, attached to the window element for smoothness.
onMount(() => {
// 50 milliseconds between successive terminal move updates.
const sendMove = throttle((message: WsClient) => {
srocket?.send(message);
}, 50);
// 80 milliseconds between successive cursor updates.
const sendCursor = throttle((message: WsClient) => {
srocket?.send(message);
}, 80);
function handleMouse(event: MouseEvent) {
if (moving !== -1 && !movingIsDone) {
const [x, y] = normalizePosition(event);
movingSize = {
...movingSize,
x: Math.round(x - movingOrigin[0]),
y: Math.round(y - movingOrigin[1]),
};
sendMove({ move: [moving, movingSize] });
}
if (resizing !== -1) {
const cols = Math.max(
Math.floor((event.pageX - resizingOrigin[0]) / resizingCell[0]),
TERM_MIN_COLS, // Minimum number of columns.
);
const rows = Math.max(
Math.floor((event.pageY - resizingOrigin[1]) / resizingCell[1]),
TERM_MIN_ROWS, // Minimum number of rows.
);
if (rows !== resizingSize.rows || cols !== resizingSize.cols) {
resizingSize = { ...resizingSize, rows, cols };
srocket?.send({ move: [resizing, resizingSize] });
}
}
sendCursor({ setCursor: normalizePosition(event) });
}
function handleMouseEnd(event: MouseEvent) {
if (moving !== -1) {
movingIsDone = true;
sendMove.cancel();
srocket?.send({ move: [moving, movingSize] });
}
if (resizing !== -1) {
resizing = -1;
}
if (event.type === "mouseleave") {
sendCursor.cancel();
srocket?.send({ setCursor: null });
}
}
window.addEventListener("mousemove", handleMouse);
window.addEventListener("mouseup", handleMouseEnd);
document.body.addEventListener("mouseleave", handleMouseEnd);
return () => {
window.removeEventListener("mousemove", handleMouse);
window.removeEventListener("mouseup", handleMouseEnd);
document.body.removeEventListener("mouseleave", handleMouseEnd);
};
});
let focused: number[] = [];
$: setFocus(focused);
// Wait a small amount of time, since blur events happen before focus events.
const setFocus = debounce((focused: number[]) => {
srocket?.send({ setFocus: focused[0] ?? null });
}, 20);
</script>
<!-- Wheel handler stops native macOS Chrome zooming on pinch. -->
<main
class="p-8"
class:cursor-nwse-resize={resizing !== -1}
on:wheel={(event) => event.preventDefault()}
>
<div
class="absolute top-8 inset-x-0 flex justify-center pointer-events-none z-10"
>
<Toolbar
{connected}
{newMessages}
{hasWriteAccess}
on:create={handleCreate}
on:chat={() => {
showChat = !showChat;
newMessages = false;
}}
on:settings={() => {
settingsOpen = true;
}}
on:networkInfo={() => {
showNetworkInfo = !showNetworkInfo;
}}
/>
{#if showNetworkInfo}
<div class="absolute top-20 translate-x-[116.5px]">
<NetworkInfo
status={connected
? "connected"
: exitReason
? "no-shell"
: "no-server"}
serverLatency={integerMedian(serverLatencies)}
shellLatency={integerMedian(shellLatencies)}
/>
</div>
{/if}
</div>
{#if showChat}
<div
class="absolute flex flex-col justify-end inset-y-4 right-4 w-80 pointer-events-none z-10"
>
<Chat
{userId}
messages={chatMessages}
on:chat={(event) => srocket?.send({ chat: event.detail })}
on:close={() => (showChat = false)}
/>
</div>
{/if}
<Settings open={settingsOpen} on:close={() => (settingsOpen = false)} />
<ChooseName />
<!--
Dotted circle background appears underneath the rest of the elements, but
moves and zooms with the fabric of the canvas.
-->
<div
class="absolute inset-0 -z-10"
style:background-image="radial-gradient(#333 {zoom}px, transparent 0)"
style:background-size="{24 * zoom}px {24 * zoom}px"
style:background-position="{-zoom * center[0]}px {-zoom * center[1]}px"
/>
<div class="py-2">
{#if exitReason !== null}
<div class="text-red-400">{exitReason}</div>
{:else if connected}
<div class="flex items-center">
<div class="text-green-400">You are connected!</div>
{#if userId && hasWriteAccess === false}
<div
class="bg-yellow-900 text-yellow-200 px-1 py-0.5 rounded ml-3 inline-flex items-center gap-1"
>
<EyeIcon size="14" />
<span class="text-xs">Read-only</span>
</div>
{/if}
</div>
{:else}
<div class="text-yellow-400">Connecting…</div>
{/if}
<div class="mt-4">
<NameList {users} />
</div>
</div>
<div class="absolute inset-0 overflow-hidden touch-none" bind:this={fabricEl}>
{#each shells as [id, winsize] (id)}
{@const ws = id === moving ? movingSize : winsize}
<div
class="absolute"
style:left={OFFSET_LEFT_CSS}
style:top={OFFSET_TOP_CSS}
style:transform-origin={OFFSET_TRANSFORM_ORIGIN_CSS}
transition:fade|local
use:slide={{ x: ws.x, y: ws.y, center, zoom, immediate: id === moving }}
bind:this={termWrappers[id]}
>
<XTerm
rows={ws.rows}
cols={ws.cols}
bind:write={writers[id]}
bind:termEl={termElements[id]}
on:data={({ detail: data }) =>
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);
}}
/>
<!-- User avatars -->
<div class="absolute bottom-2.5 right-2.5 pointer-events-none">
<Avatars
users={users.filter(
([uid, user]) => uid !== userId && user.focus === id,
)}
/>
</div>
<!-- Interactable element for resizing -->
<div
class="absolute w-5 h-5 -bottom-1 -right-1 cursor-nwse-resize"
on:mousedown={(event) => {
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()}
/>
</div>
{/each}
{#each users.filter(([id, user]) => id !== userId && user.cursor !== null) as [id, user] (id)}
<div
class="absolute"
style:left={OFFSET_LEFT_CSS}
style:top={OFFSET_TOP_CSS}
style:transform-origin={OFFSET_TRANSFORM_ORIGIN_CSS}
transition:fade|local={{ duration: 200 }}
use:slide={{
x: user.cursor?.[0] ?? 0,
y: user.cursor?.[1] ?? 0,
center,
zoom,
}}
>
<LiveCursor {user} />
</div>
{/each}
</div>
</main>
================================================
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<HTMLElement, SlideParams> = (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<HTMLElement, SlideParams> = (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 <https://github.com/ekzhang/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<T extends (...args: any[]) => void>(fn: T, ms = 0) {
let timeoutId: number | any;
return function (...args: Parameters<T>) {
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<Encrypt> {
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<Uint8Array> {
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<Uint8Array> {
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 <https://stackoverflow.com/a/74538176>.
export function createLock() {
const queue: (() => Promise<void>)[] = [];
let active = false;
return (fn: () => Promise<void>) => {
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<Partial<Settings>>("sshx-settings-store", {});
/** A persisted store for settings of the current user. */
export const settings: Readable<Settings> = 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<Settings>) {
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<T> = {
/** 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<T, U> {
#url: string;
#options: SrocketOptions<T>;
#ws: WebSocket | null;
#connected: boolean;
#buffer: Uint8Array[];
#disposed: boolean;
constructor(url: string, options: SrocketOptions<T>) {
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 = <Uint8Array>(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<T extends IDisposable>(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
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
SYMBOL INDEX (391 symbols across 37 files)
FILE: crates/sshx-core/build.rs
function main (line 3) | fn main() -> Result<(), Box<dyn std::error::Error>> {
FILE: crates/sshx-core/src/lib.rs
constant FILE_DESCRIPTOR_SET (line 18) | pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_se...
function rand_alphanumeric (line 22) | pub fn rand_alphanumeric(len: usize) -> String {
type Sid (line 34) | pub struct Sid(pub u32);
method fmt (line 37) | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
type Uid (line 45) | pub struct Uid(pub u32);
method fmt (line 48) | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
type IdCounter (line 55) | pub struct IdCounter {
method next_sid (line 71) | pub fn next_sid(&self) -> Sid {
method next_uid (line 76) | pub fn next_uid(&self) -> Uid {
method get_current_values (line 81) | pub fn get_current_values(&self) -> (Sid, Uid) {
method set_current_values (line 89) | pub fn set_current_values(&self, sid: Sid, uid: Uid) {
method default (line 61) | fn default() -> Self {
FILE: crates/sshx-server/src/grpc.rs
constant SYNC_INTERVAL (line 23) | pub const SYNC_INTERVAL: Duration = Duration::from_secs(5);
constant PING_INTERVAL (line 26) | pub const PING_INTERVAL: Duration = Duration::from_secs(2);
type GrpcServer (line 30) | pub struct GrpcServer(Arc<ServerState>);
method new (line 34) | pub fn new(state: Arc<ServerState>) -> Self {
type RR (line 39) | type RR<T> = Result<Response<T>, Status>;
type ChannelStream (line 43) | type ChannelStream = ReceiverStream<Result<ServerUpdate, Status>>;
method open (line 45) | async fn open(&self, request: Request<OpenRequest>) -> RR<OpenResponse> {
method channel (line 74) | async fn channel(&self, request: Request<Streaming<ClientUpdate>>) -> RR...
method close (line 112) | async fn close(&self, request: Request<CloseRequest>) -> RR<CloseRespons...
function validate_token (line 126) | fn validate_token(mac: impl Mac, name: &str, token: &str) -> tonic::Resu...
type ServerTx (line 135) | type ServerTx = mpsc::Sender<Result<ServerUpdate, Status>>;
function handle_streaming (line 138) | async fn handle_streaming(
function handle_update (line 190) | async fn handle_update(tx: &ServerTx, session: &Session, update: ClientU...
function send_msg (line 227) | async fn send_msg(tx: &ServerTx, message: ServerMessage) -> bool {
function send_err (line 235) | async fn send_err(tx: &ServerTx, err: String) -> bool {
function get_time_ms (line 239) | fn get_time_ms() -> u64 {
FILE: crates/sshx-server/src/lib.rs
type ServerOptions (line 35) | pub struct ServerOptions {
type Server (line 50) | pub struct Server {
method new (line 57) | pub fn new(options: ServerOptions) -> Result<Self> {
method state (line 65) | pub fn state(&self) -> Arc<ServerState> {
method listen (line 70) | pub async fn listen<L>(&self, listener: L) -> Result<()>
method bind (line 95) | pub async fn bind(&self, addr: &SocketAddr) -> Result<()> {
method shutdown (line 105) | pub fn shutdown(&self) {
FILE: crates/sshx-server/src/listen.rs
function start_server (line 18) | pub(crate) async fn start_server<L>(
FILE: crates/sshx-server/src/main.rs
type Args (line 15) | struct Args {
function start (line 42) | async fn start(args: Args) -> Result<()> {
function main (line 76) | fn main() -> ExitCode {
FILE: crates/sshx-server/src/session.rs
constant SHELL_STORED_BYTES (line 26) | const SHELL_STORED_BYTES: u64 = 1 << 21;
type Metadata (line 30) | pub struct Metadata {
type Session (line 43) | pub struct Session {
method new (line 106) | pub fn new(metadata: Metadata) -> Self {
method metadata (line 125) | pub fn metadata(&self) -> &Metadata {
method counter (line 130) | pub fn counter(&self) -> &IdCounter {
method sequence_numbers (line 135) | pub fn sequence_numbers(&self) -> SequenceNumbers {
method subscribe_broadcast (line 147) | pub fn subscribe_broadcast(
method subscribe_shells (line 154) | pub fn subscribe_shells(&self) -> impl Stream<Item = Vec<(Sid, WsWinsi...
method subscribe_chunks (line 159) | pub fn subscribe_chunks(
method add_shell (line 200) | pub fn add_shell(&self, id: Sid, center: (i32, i32)) -> Result<()> {
method close_shell (line 219) | pub fn close_shell(&self, id: Sid) -> Result<()> {
method get_shell_mut (line 235) | fn get_shell_mut(&self, id: Sid) -> Result<impl DerefMut<Target = Stat...
method move_shell (line 247) | pub fn move_shell(&self, id: Sid, winsize: Option<WsWinsize>) -> Resul...
method add_data (line 259) | pub fn add_data(&self, id: Sid, data: Bytes, seq: u64) -> Result<()> {
method list_users (line 290) | pub fn list_users(&self) -> Vec<(Uid, WsUser)> {
method update_user (line 299) | pub fn update_user(&self, id: Uid, f: impl FnOnce(&mut WsUser)) -> Res...
method user_scope (line 313) | pub fn user_scope(&self, id: Uid, can_write: bool) -> Result<impl Drop...
method remove_user (line 341) | fn remove_user(&self, id: Uid) {
method check_write_permission (line 349) | pub fn check_write_permission(&self, user_id: Uid) -> Result<()> {
method send_chat (line 359) | pub fn send_chat(&self, id: Uid, msg: &str) -> Result<()> {
method send_latency_measurement (line 372) | pub fn send_latency_measurement(&self, latency: u64) {
method access (line 377) | pub fn access(&self) {
method last_accessed (line 382) | pub fn last_accessed(&self) -> Instant {
method update_tx (line 387) | pub fn update_tx(&self) -> &async_channel::Sender<ServerMessage> {
method update_rx (line 392) | pub fn update_rx(&self) -> &async_channel::Receiver<ServerMessage> {
method sync_now (line 406) | pub fn sync_now(&self) {
method sync_now_wait (line 411) | pub async fn sync_now_wait(&self) {
method shutdown (line 416) | pub fn shutdown(&self) {
method terminated (line 421) | pub async fn terminated(&self) {
type State (line 84) | struct State {
FILE: crates/sshx-server/src/session/snapshot.rs
constant SHELL_SNAPSHOT_BYTES (line 16) | const SHELL_SNAPSHOT_BYTES: u64 = 1 << 15;
constant MAX_SNAPSHOT_SIZE (line 18) | const MAX_SNAPSHOT_SIZE: usize = 1 << 22;
method snapshot (line 22) | pub fn snapshot(&self) -> Result<Vec<u8>> {
method restore (line 73) | pub fn restore(data: &[u8]) -> Result<Self> {
FILE: crates/sshx-server/src/state.rs
constant DISCONNECTED_SESSION_EXPIRY (line 27) | const DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300);
type ServerState (line 30) | pub struct ServerState {
method new (line 46) | pub fn new(options: ServerOptions) -> Result<Self> {
method mac (line 61) | pub fn mac(&self) -> Hmac<Sha256> {
method override_origin (line 66) | pub fn override_origin(&self) -> Option<String> {
method lookup (line 71) | pub fn lookup(&self, name: &str) -> Option<Arc<Session>> {
method insert (line 76) | pub fn insert(&self, name: &str, session: Arc<Session>) {
method remove (line 91) | pub fn remove(&self, name: &str) -> bool {
method close_session (line 101) | pub async fn close_session(&self, name: &str) -> Result<()> {
method backend_connect (line 111) | pub async fn backend_connect(&self, name: &str) -> Result<Option<Arc<S...
method frontend_connect (line 132) | pub async fn frontend_connect(
method listen_for_transfers (line 153) | pub async fn listen_for_transfers(&self) {
method close_old_sessions (line 163) | pub async fn close_old_sessions(&self) {
method shutdown (line 182) | pub fn shutdown(&self) {
FILE: crates/sshx-server/src/state/mesh.rs
constant STORAGE_SYNC_INTERVAL (line 14) | const STORAGE_SYNC_INTERVAL: Duration = Duration::from_secs(20);
constant STORAGE_EXPIRY (line 17) | const STORAGE_EXPIRY: Duration = Duration::from_secs(300);
function set_opts (line 19) | fn set_opts() -> redis::SetOptions {
type StorageMesh (line 33) | pub struct StorageMesh {
method new (line 41) | pub fn new(redis_url: &str, host: Option<&str>) -> Result<Self> {
method host (line 66) | pub fn host(&self) -> Option<&str> {
method get_owner (line 71) | pub async fn get_owner(&self, name: &str) -> Result<Option<String>> {
method get_owner_snapshot (line 86) | pub async fn get_owner_snapshot(
method background_sync (line 105) | pub async fn background_sync(&self, name: &str, session: Arc<Session>) {
method mark_closed (line 141) | pub async fn mark_closed(&self, name: &str) -> Result<()> {
method notify_transfer (line 158) | pub async fn notify_transfer(&self, name: &str, host: &str) -> Result<...
method listen_for_transfers (line 165) | pub fn listen_for_transfers(&self) -> impl Stream<Item = String> + Sen...
FILE: crates/sshx-server/src/utils.rs
type Shutdown (line 12) | pub struct Shutdown {
method new (line 18) | pub fn new() -> Self {
method shutdown (line 25) | pub fn shutdown(&self) {
method is_terminated (line 31) | pub fn is_terminated(&self) -> bool {
method wait (line 36) | pub fn wait(&'_ self) -> impl Future<Output = ()> + Send {
method default (line 52) | fn default() -> Self {
method fmt (line 58) | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
FILE: crates/sshx-server/src/web.rs
function app (line 15) | pub fn app() -> Router<Arc<ServerState>> {
function backend (line 32) | fn backend() -> Router<Arc<ServerState>> {
FILE: crates/sshx-server/src/web/protocol.rs
type WsWinsize (line 10) | pub struct WsWinsize {
method default (line 22) | fn default() -> Self {
type WsUser (line 35) | pub struct WsUser {
type WsServer (line 49) | pub enum WsServer {
type WsClient (line 75) | pub enum WsClient {
FILE: crates/sshx-server/src/web/socket.rs
function get_session_ws (line 23) | pub async fn get_session_ws(
function handle_socket (line 73) | async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) ->...
function proxy_redirect (line 255) | async fn proxy_redirect(socket: &mut WebSocket, host: &str, name: &str) ...
FILE: crates/sshx-server/tests/common/mod.rs
type TestServer (line 24) | pub struct TestServer {
method new (line 34) | pub async fn new() -> Self {
method local_addr (line 53) | pub fn local_addr(&self) -> SocketAddr {
method endpoint (line 58) | pub fn endpoint(&self) -> String {
method ws_endpoint (line 63) | pub fn ws_endpoint(&self, name: &str) -> String {
method grpc_client (line 68) | pub async fn grpc_client(&self) -> SshxServiceClient<Channel> {
method state (line 73) | pub fn state(&self) -> Arc<ServerState> {
method drop (line 79) | fn drop(&mut self) {
type ClientSocket (line 85) | pub struct ClientSocket {
method connect (line 100) | pub async fn connect(uri: &str, key: &str, write_password: Option<&str...
method authenticate (line 119) | async fn authenticate(&mut self) {
method send (line 127) | pub async fn send(&mut self, msg: WsClient) {
method send_input (line 133) | pub async fn send_input(&mut self, id: Sid, data: &[u8]) {
method recv (line 139) | async fn recv(&mut self) -> Option<WsServer> {
method expect_close (line 152) | pub async fn expect_close(&mut self, code: u16) {
method flush (line 160) | pub async fn flush(&mut self) {
method read (line 199) | pub fn read(&self, id: Sid) -> &str {
FILE: crates/sshx-server/tests/simple.rs
function test_rpc (line 10) | async fn test_rpc() -> Result<()> {
function test_web_get (line 27) | async fn test_web_get() -> Result<()> {
FILE: crates/sshx-server/tests/snapshot.rs
function test_basic_restore (line 16) | async fn test_basic_restore() -> Result<()> {
FILE: crates/sshx-server/tests/with_client.rs
function test_handshake (line 15) | async fn test_handshake() -> Result<()> {
function test_command (line 23) | async fn test_command() -> Result<()> {
function test_ws_missing (line 56) | async fn test_ws_missing() -> Result<()> {
function test_ws_basic (line 71) | async fn test_ws_basic() -> Result<()> {
function test_ws_resize (line 103) | async fn test_ws_resize() -> Result<()> {
function test_users_join (line 147) | async fn test_users_join() -> Result<()> {
function test_users_metadata (line 176) | async fn test_users_metadata() -> Result<()> {
function test_chat_messages (line 201) | async fn test_chat_messages() -> Result<()> {
function test_read_write_permissions (line 233) | async fn test_read_write_permissions() -> Result<()> {
FILE: crates/sshx/examples/stdin_client.rs
function main (line 13) | async fn main() -> Result<()> {
FILE: crates/sshx/src/controller.rs
constant HEARTBEAT_INTERVAL (line 23) | const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
constant RECONNECT_INTERVAL (line 26) | const RECONNECT_INTERVAL: Duration = Duration::from_secs(60);
type Controller (line 29) | pub struct Controller {
method new (line 50) | pub async fn new(
method connect (line 119) | async fn connect(origin: &str) -> Result<SshxServiceClient<Channel>, t...
method name (line 124) | pub fn name(&self) -> &str {
method url (line 129) | pub fn url(&self) -> &str {
method write_url (line 134) | pub fn write_url(&self) -> Option<&str> {
method encryption_key (line 139) | pub fn encryption_key(&self) -> &str {
method run (line 144) | pub async fn run(&mut self) -> ! {
method try_channel (line 162) | async fn try_channel(&mut self) -> Result<()> {
method spawn_shell_task (line 249) | fn spawn_shell_task(&mut self, id: Sid, center: (i32, i32)) {
method close (line 277) | pub async fn close(&self) -> Result<()> {
function send_msg (line 290) | async fn send_msg(tx: &mpsc::Sender<ClientUpdate>, message: ClientMessag...
FILE: crates/sshx/src/encrypt.rs
type Aes128Ctr64BE (line 5) | type Aes128Ctr64BE = ctr::Ctr64BE<aes::Aes128>;
constant SALT (line 9) | const SALT: &str =
type Encrypt (line 14) | pub struct Encrypt {
method new (line 20) | pub fn new(key: &str) -> Self {
method zeros (line 36) | pub fn zeros(&self) -> Vec<u8> {
method segment (line 47) | pub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Ve...
function make_encrypt (line 66) | fn make_encrypt() {
function roundtrip_ctr (line 75) | fn roundtrip_ctr() {
function matches_offset (line 85) | fn matches_offset() {
function zero_stream_num (line 98) | fn zero_stream_num() {
FILE: crates/sshx/src/main.rs
type Args (line 13) | struct Args {
function print_greeting (line 36) | fn print_greeting(shell: &str, controller: &Controller) {
function start (line 75) | async fn start(args: Args) -> Result<()> {
function main (line 115) | fn main() -> ExitCode {
FILE: crates/sshx/src/runner.rs
constant CONTENT_CHUNK_SIZE (line 15) | const CONTENT_CHUNK_SIZE: usize = 1 << 16;
constant CONTENT_ROLLING_BYTES (line 16) | const CONTENT_ROLLING_BYTES: usize = 8 << 20;
constant CONTENT_PRUNE_BYTES (line 17) | const CONTENT_PRUNE_BYTES: usize = 12 << 20;
type Runner (line 21) | pub enum Runner {
method run (line 41) | pub async fn run(
type ShellData (line 30) | pub enum ShellData {
function shell_task (line 56) | async fn shell_task(
function prev_char_boundary (line 143) | fn prev_char_boundary(s: &str, i: usize) -> usize {
function echo_task (line 150) | async fn echo_task(
FILE: crates/sshx/src/terminal.rs
function winsize (line 24) | async fn winsize() -> Result<()> {
FILE: crates/sshx/src/terminal/unix.rs
function get_default_shell (line 22) | pub async fn get_default_shell() -> String {
type Terminal (line 43) | pub struct Terminal {
method new (line 54) | pub async fn new(shell: &str) -> Result<Terminal> {
method fork_child (line 77) | fn fork_child(shell: &str, slave_port: RawFd) -> Result<Pid> {
method execv_child (line 91) | fn execv_child(shell: &CStr, slave_port: RawFd) -> Result<Infallible, ...
method get_winsize (line 109) | pub fn get_winsize(&self) -> Result<(u16, u16)> {
method set_winsize (line 118) | pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> {
method poll_read (line 129) | fn poll_read(
method poll_write (line 140) | fn poll_write(
method poll_flush (line 148) | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Re...
method poll_shutdown (line 152) | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io:...
method drop (line 159) | fn drop(self: Pin<&mut Self>) {
function make_winsize (line 174) | fn make_winsize(rows: u16, cols: u16) -> Winsize {
FILE: crates/sshx/src/terminal/windows.rs
function get_default_shell (line 20) | pub async fn get_default_shell() -> String {
type Terminal (line 34) | pub struct Terminal {
method new (line 46) | pub async fn new(shell: &str) -> Result<Terminal> {
method get_winsize (line 69) | pub fn get_winsize(&self) -> Result<(u16, u16)> {
method set_winsize (line 74) | pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> {
method poll_read (line 85) | fn poll_read(
method poll_write (line 96) | fn poll_write(
method poll_flush (line 104) | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Re...
method poll_shutdown (line 108) | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io:...
method drop (line 115) | fn drop(self: Pin<&mut Self>) {
FILE: src/lib/action/slide.ts
type SlideParams (line 6) | type SlideParams = {
method update (line 28) | update(params) {
method destroy (line 35) | destroy() {
method update (line 57) | update(params) {
method destroy (line 62) | destroy() {
FILE: src/lib/action/touchZoom.ts
function isDarwin (line 40) | function isDarwin(): boolean {
function debounce (line 44) | function debounce<T extends (...args: any[]) => void>(fn: T, ms = 0) {
constant MIN_ZOOM (line 52) | const MIN_ZOOM = 0.35;
constant MAX_ZOOM (line 53) | const MAX_ZOOM = 2;
constant INITIAL_ZOOM (line 54) | const INITIAL_ZOOM = 1.0;
class TouchZoom (line 56) | class TouchZoom {
method constructor (line 83) | constructor(node: HTMLElement) {
method #getPoint (line 127) | #getPoint(e: PointerEvent | Touch | WheelEvent): number[] {
method onMove (line 148) | onMove(callback: (manual: boolean) => void): () => void {
method moveTo (line 153) | async moveTo(pos: number[], zoom: number) {
method #moved (line 181) | #moved(manual = true) {
method destroy (line 283) | destroy() {
constant MAX_ZOOM_STEP (line 302) | const MAX_ZOOM_STEP = 10;
function normalizeWheel (line 305) | function normalizeWheel(event: WheelEvent) {
FILE: src/lib/arrange.ts
constant ISECT_W (line 1) | const ISECT_W = 752;
constant ISECT_H (line 2) | const ISECT_H = 515;
constant ISECT_PAD (line 3) | const ISECT_PAD = 16;
type ExistingTerminal (line 5) | type ExistingTerminal = {
function arrangeNewTerminal (line 13) | function arrangeNewTerminal(existing: ExistingTerminal[]) {
function isect (line 41) | function isect(s1: number, e1: number, s2: number, e2: number): boolean {
FILE: src/lib/encrypt.ts
constant SALT (line 8) | const SALT: string =
class Encrypt (line 11) | class Encrypt {
method constructor (line 12) | private constructor(private aesKey: CryptoKey) {}
method new (line 14) | static async new(key: string): Promise<Encrypt> {
method zeros (line 41) | async zeros(): Promise<Uint8Array> {
method segment (line 51) | async segment(
FILE: src/lib/lock.ts
function createLock (line 3) | function createLock() {
FILE: src/lib/protocol.ts
type Sid (line 1) | type Sid = number;
type Uid (line 2) | type Uid = number;
type WsWinsize (line 5) | type WsWinsize = {
type WsUser (line 13) | type WsUser = {
type WsServer (line 21) | type WsServer = {
type WsClient (line 35) | type WsClient = {
FILE: src/lib/settings.ts
type Settings (line 5) | type Settings = {
function updateSettings (line 38) | function updateSettings(values: Partial<Settings>) {
FILE: src/lib/srocket.ts
constant RECONNECT_DELAY (line 11) | const RECONNECT_DELAY = 500;
constant BUFFER_SIZE (line 14) | const BUFFER_SIZE = 64;
type SrocketOptions (line 16) | type SrocketOptions<T> = {
class Srocket (line 31) | class Srocket<T, U> {
method constructor (line 40) | constructor(url: string, options: SrocketOptions<T>) {
method connected (line 58) | get connected() {
method send (line 63) | send(message: U) {
method dispose (line 78) | dispose() {
method #reconnect (line 84) | #reconnect() {
method #stateChange (line 110) | #stateChange(connected: boolean) {
FILE: src/lib/toast.ts
type Toast (line 7) | type Toast = {
function makeToast (line 14) | function makeToast(toast: Toast, duration = 3000) {
FILE: src/lib/typeahead.ts
method dispose (line 23) | dispose(): void {
method _register (line 32) | protected _register<T extends IDisposable>(o: T): T {
function toDisposable (line 48) | function toDisposable(fn: () => void): IDisposable {
function disposableTimeout (line 61) | function disposableTimeout(handler: () => void, timeout = 0): IDisposable {
type Event (line 71) | interface Event<T> {
class Emitter (line 76) | class Emitter<T> {
method dispose (line 81) | dispose() {
method event (line 87) | get event(): Event<T> {
method fire (line 100) | fire(event: T): void {
function escapeRegExpCharacters (line 111) | function escapeRegExpCharacters(value: string): string {
function createDecorator (line 117) | function createDecorator(
type IDebounceReducer (line 141) | interface IDebounceReducer<T> {
function debounce (line 146) | function debounce<T>(
type VT (line 181) | const enum VT {
constant CSI_STYLE_RE (line 190) | const CSI_STYLE_RE = /^\x1b\[[0-9;]*m/;
constant CSI_MOVE_RE (line 191) | const CSI_MOVE_RE = /^\x1b\[?([0-9]*)(;[35])?O?([DC])/;
constant NOT_WORD_RE (line 192) | const NOT_WORD_RE = /[^a-z0-9]/i;
type StatsConstants (line 194) | const enum StatsConstants {
constant PREDICTION_OMIT_RE (line 213) | const PREDICTION_OMIT_RE = /^(\x1b\[(\??25[hl]|\??[0-9;]+n))+/;
type CursorMoveDirection (line 221) | const enum CursorMoveDirection {
type ICoordinate (line 226) | interface ICoordinate {
class Cursor (line 232) | class Cursor implements ICoordinate {
method x (line 237) | get x() {
method y (line 241) | get y() {
method baseY (line 245) | get baseY() {
method coordinate (line 249) | get coordinate(): ICoordinate {
method constructor (line 253) | constructor(
method getLine (line 263) | getLine() {
method getCell (line 267) | getCell(loadInto?: IBufferCell) {
method moveTo (line 271) | moveTo(coordinate: ICoordinate) {
method clone (line 277) | clone() {
method move (line 283) | move(x: number, y: number) {
method shift (line 289) | shift(x: number = 0, y: number = 0) {
method moveInstruction (line 295) | moveInstruction() {
type MatchResult (line 338) | const enum MatchResult {
type IPrediction (line 347) | interface IPrediction {
class StringReader (line 390) | class StringReader {
method remaining (line 393) | get remaining() {
method eof (line 397) | get eof() {
method rest (line 401) | get rest() {
method constructor (line 405) | constructor(private readonly _input: string) {}
method eatChar (line 410) | eatChar(char: string) {
method eatStr (line 422) | eatStr(substr: string) {
method eatGradually (line 436) | eatGradually(substr: string): MatchResult {
method eatRe (line 455) | eatRe(re: RegExp) {
method eatCharCode (line 468) | eatCharCode(min = 0, max = min + 1) {
class HardBoundary (line 483) | class HardBoundary implements IPrediction {
method apply (line 486) | apply() {
method rollback (line 490) | rollback() {
method rollForwards (line 494) | rollForwards() {
method matches (line 498) | matches() {
class TentativeBoundary (line 507) | class TentativeBoundary implements IPrediction {
method constructor (line 510) | constructor(readonly inner: IPrediction) {}
method apply (line 512) | apply(buffer: IBuffer, cursor: Cursor) {
method rollback (line 518) | rollback(cursor: Cursor) {
method rollForwards (line 523) | rollForwards(cursor: Cursor, withInput: string) {
method matches (line 531) | matches(input: StringReader) {
class CharacterPrediction (line 544) | class CharacterPrediction implements IPrediction {
method constructor (line 553) | constructor(
method apply (line 558) | apply(_: IBuffer, cursor: Cursor) {
method rollback (line 573) | rollback(cursor: Cursor) {
method rollForwards (line 587) | rollForwards(cursor: Cursor, input: string) {
method matches (line 595) | matches(input: StringReader, lookBehind?: IPrediction) {
class BackspacePrediction (line 624) | class BackspacePrediction implements IPrediction {
method constructor (line 632) | constructor(private readonly _terminal: Terminal) {}
method apply (line 634) | apply(_: IBuffer, cursor: Cursor) {
method rollback (line 656) | rollback(cursor: Cursor) {
method rollForwards (line 674) | rollForwards() {
method matches (line 678) | matches(input: StringReader) {
class NewlinePrediction (line 695) | class NewlinePrediction implements IPrediction {
method apply (line 698) | apply(_: IBuffer, cursor: Cursor) {
method rollback (line 704) | rollback(cursor: Cursor) {
method rollForwards (line 708) | rollForwards() {
method matches (line 712) | matches(input: StringReader) {
class LinewrapPrediction (line 721) | class LinewrapPrediction extends NewlinePrediction implements IPrediction {
method apply (line 722) | override apply(_: IBuffer, cursor: Cursor) {
method matches (line 728) | override matches(input: StringReader) {
class CursorMovePrediction (line 741) | class CursorMovePrediction implements IPrediction {
method constructor (line 749) | constructor(
method apply (line 755) | apply(buffer: IBuffer, cursor: Cursor) {
method rollback (line 786) | rollback(cursor: Cursor) {
method rollForwards (line 797) | rollForwards() {
method matches (line 801) | matches(input: StringReader) {
class PredictionStats (line 836) | class PredictionStats extends Disposable {
method accuracy (line 846) | get accuracy() {
method sampleSize (line 860) | get sampleSize() {
method latency (line 867) | get latency() {
method maxLatency (line 884) | get maxLatency() {
method constructor (line 895) | constructor(timeline: PredictionTimeline) {
method _pushStat (line 908) | private _pushStat(correct: boolean, prediction: IPrediction) {
class PredictionTimeline (line 916) | class PredictionTimeline {
method _currentGenerationPredictions (line 968) | private get _currentGenerationPredictions() {
method isShowingPredictions (line 974) | get isShowingPredictions() {
method length (line 978) | get length() {
method constructor (line 982) | constructor(
method setShowPredictions (line 987) | setShowPredictions(show: boolean) {
method undoAllPredictions (line 1024) | undoAllPredictions() {
method beforeServerInput (line 1041) | beforeServerInput(input: string): string {
method _clearPredictionState (line 1167) | private _clearPredictionState() {
method addPrediction (line 1176) | addPrediction(buffer: IBuffer, prediction: IPrediction) {
method addBoundary (line 1206) | addBoundary(buffer?: IBuffer, prediction?: IPrediction) {
method peekEnd (line 1222) | peekEnd(): IPrediction | undefined {
method peekStart (line 1229) | peekStart(): IPrediction | undefined {
method physicalCursor (line 1236) | physicalCursor(buffer: IBuffer) {
method tentativeCursor (line 1255) | tentativeCursor(buffer: IBuffer) {
method clearCursor (line 1263) | clearCursor() {
method _getActiveBuffer (line 1268) | private _getActiveBuffer() {
class TypeAheadStyle (line 1402) | class TypeAheadStyle implements IDisposable {
method _compileArgs (line 1403) | private static _compileArgs(args: ReadonlyArray<number>) {
method constructor (line 1420) | constructor(value: string, private readonly _terminal: Terminal) {
method expectIncomingStyle (line 1428) | expectIncomingStyle(n = 1) {
method startTracking (line 1435) | startTracking() {
method debounceStopTracking (line 1453) | debounceStopTracking() {
method dispose (line 1460) | dispose() {
method _stopTracking (line 1464) | private _stopTracking() {
method _onDidWriteSGR (line 1469) | private _onDidWriteSGR(args: (number | number[])[]) {
method onUpdate (line 1532) | onUpdate(style: string) {
method _getArgs (line 1540) | private _getArgs(style: string) {
type CharPredictState (line 1589) | const enum CharPredictState {
class TypeAheadAddon (line 1598) | class TypeAheadAddon extends Disposable implements ITerminalAddon {
method constructor (line 1624) | constructor() {
method activate (line 1632) | activate(terminal: Terminal): void {
method reset (line 1703) | reset() {
method _deferClearingPredictions (line 1707) | private _deferClearingPredictions() {
method _reevaluatePredictorState (line 1737) | protected _reevaluatePredictorState(
method _reevaluatePredictorStateNow (line 1744) | protected _reevaluatePredictorStateNow(
method _sendLatencyStats (line 1770) | private _sendLatencyStats(stats: PredictionStats) {
method _onUserData (line 1788) | private _onUserData(data: string): void {
method onBeforeProcessData (line 1953) | onBeforeProcessData(data: string): string {
FILE: src/lib/ui/themes.ts
type ThemeName (line 217) | type ThemeName = keyof typeof themes;
Condensed preview — 90 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (313K chars).
[
{
"path": ".editorconfig",
"chars": 160,
"preview": "[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.rs]\ntab_width = 4\n\n[*.{js,jsx,ts,ts"
},
{
"path": ".eslintrc.cjs",
"chars": 967,
"preview": "module.exports = {\n root: true,\n parser: \"@typescript-eslint/parser\",\n extends: [\n \"eslint:recommended\",\n \"plug"
},
{
"path": ".github/workflows/ci.yaml",
"chars": 1643,
"preview": "name: CI\n\non:\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n\njobs:\n rustfmt:\n name: "
},
{
"path": ".gitignore",
"chars": 53,
"preview": "/.vscode\n\n/target\n\n/node_modules\n/.svelte-kit\n/build\n"
},
{
"path": ".prettierrc",
"chars": 54,
"preview": "{\n \"proseWrap\": \"always\",\n \"trailingComma\": \"all\"\n}\n"
},
{
"path": "Cargo.toml",
"chars": 962,
"preview": "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.4.1\"\nauthors = [\"Eric Zhang <ekzhang"
},
{
"path": "Cross.toml",
"chars": 645,
"preview": "[target.x86_64-unknown-freebsd]\npre-build = [\n \"apt-get update\",\n\n # Protobuf version is too outdated on the cargo"
},
{
"path": "Dockerfile",
"chars": 643,
"preview": "FROM rust:alpine AS backend\nWORKDIR /home/rust/src\nRUN apk --no-cache add musl-dev openssl-dev protoc\nRUN rustup compone"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2023 Eric Zhang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 3158,
"preview": "# sshx\n\nA secure web-based, collaborative terminal.\n\n\n\n**Features:**\n\n- Run a single"
},
{
"path": "compose.yaml",
"chars": 325,
"preview": "# Services used by sshx for development. These listen on ports 126XX, to reduce the chance that they\n# conflict with oth"
},
{
"path": "crates/sshx/Cargo.toml",
"chars": 893,
"preview": "[package]\nname = \"sshx\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.workspace"
},
{
"path": "crates/sshx/examples/stdin_client.rs",
"chars": 1509,
"preview": "use std::io::Read;\nuse std::sync::Arc;\nuse std::thread;\n\nuse anyhow::Result;\nuse sshx::terminal::{get_default_shell, Ter"
},
{
"path": "crates/sshx/src/controller.rs",
"chars": 11122,
"preview": "//! Network gRPC client allowing server control of terminals.\n\nuse std::collections::HashMap;\nuse std::pin::pin;\n\nuse an"
},
{
"path": "crates/sshx/src/encrypt.rs",
"chars": 3290,
"preview": "//! Encryption of byte streams based on a random key.\n\nuse aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};\n\nty"
},
{
"path": "crates/sshx/src/lib.rs",
"chars": 335,
"preview": "//! Library code for the sshx command-line client application.\n//!\n//! This crate does not forbid use of unsafe code bec"
},
{
"path": "crates/sshx/src/main.rs",
"chars": 3694,
"preview": "use std::process::ExitCode;\n\nuse ansi_term::Color::{Cyan, Fixed, Green};\nuse anyhow::Result;\nuse clap::Parser;\nuse sshx:"
},
{
"path": "crates/sshx/src/runner.rs",
"chars": 6238,
"preview": "//! Defines tasks that control the behavior of a single shell in the client.\n\nuse anyhow::Result;\nuse encoding_rs::{Code"
},
{
"path": "crates/sshx/src/terminal/unix.rs",
"chars": 5936,
"preview": "use std::convert::Infallible;\nuse std::env;\nuse std::ffi::{CStr, CString};\nuse std::os::fd::{AsRawFd, RawFd};\nuse std::p"
},
{
"path": "crates/sshx/src/terminal/windows.rs",
"chars": 3486,
"preview": "use std::pin::Pin;\nuse std::process::Command;\nuse std::task::Context;\nuse std::task::Poll;\n\nuse anyhow::Result;\nuse pin_"
},
{
"path": "crates/sshx/src/terminal.rs",
"chars": 842,
"preview": "//! Terminal driver, which communicates with a shell subprocess through PTY.\n\n#![allow(unsafe_code)]\n\ncfg_if::cfg_if! {\n"
},
{
"path": "crates/sshx-core/Cargo.toml",
"chars": 393,
"preview": "[package]\nname = \"sshx-core\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.work"
},
{
"path": "crates/sshx-core/build.rs",
"chars": 351,
"preview": "use std::{env, path::PathBuf};\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n let descriptor_path = PathBuf:"
},
{
"path": "crates/sshx-core/proto/sshx.proto",
"chars": 4018,
"preview": "// This file contains the service definition for sshx, used by the client to\n// communicate their terminal state over gR"
},
{
"path": "crates/sshx-core/src/lib.rs",
"chars": 2633,
"preview": "//! The core crate for shared code used in the sshx application.\n\n#![forbid(unsafe_code)]\n#![warn(missing_docs)]\n\nuse st"
},
{
"path": "crates/sshx-server/Cargo.toml",
"chars": 1368,
"preview": "[package]\nname = \"sshx-server\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.wo"
},
{
"path": "crates/sshx-server/src/grpc.rs",
"chars": 9318,
"preview": "//! Defines gRPC routes and application request logic.\n\nuse std::sync::Arc;\nuse std::time::{Duration, SystemTime};\n\nuse "
},
{
"path": "crates/sshx-server/src/lib.rs",
"chars": 3503,
"preview": "//! The sshx server, which coordinates terminal sharing.\n//!\n//! Requests are communicated to the server via gRPC (for c"
},
{
"path": "crates/sshx-server/src/listen.rs",
"chars": 2122,
"preview": "use std::{fmt::Debug, future::Future, sync::Arc};\n\nuse anyhow::Result;\nuse axum::body::Body;\nuse axum::serve::Listener;\n"
},
{
"path": "crates/sshx-server/src/main.rs",
"chars": 2369,
"preview": "use std::{\n net::{IpAddr, SocketAddr},\n process::ExitCode,\n};\n\nuse anyhow::Result;\nuse clap::Parser;\nuse sshx_serv"
},
{
"path": "crates/sshx-server/src/session/snapshot.rs",
"chars": 4267,
"preview": "//! Snapshot and restore sessions from serialized state.\n\nuse std::collections::BTreeMap;\n\nuse anyhow::{ensure, Context,"
},
{
"path": "crates/sshx-server/src/session.rs",
"chars": 14683,
"preview": "//! Core logic for sshx sessions, independent of message transport.\n\nuse std::collections::HashMap;\nuse std::ops::DerefM"
},
{
"path": "crates/sshx-server/src/state/mesh.rs",
"chars": 7445,
"preview": "//! Storage and distributed communication.\n\nuse std::{pin::pin, sync::Arc, time::Duration};\n\nuse anyhow::Result;\nuse red"
},
{
"path": "crates/sshx-server/src/state.rs",
"chars": 6032,
"preview": "//! Stateful components of the server, managing multiple sessions.\n\nuse std::pin::pin;\nuse std::sync::Arc;\nuse std::time"
},
{
"path": "crates/sshx-server/src/utils.rs",
"chars": 1720,
"preview": "//! Utility functions shared among server logic.\n\nuse std::fmt::Debug;\nuse std::future::Future;\nuse std::sync::atomic::{"
},
{
"path": "crates/sshx-server/src/web/protocol.rs",
"chars": 3410,
"preview": "//! Serializable types sent and received by the web server.\n\nuse bytes::Bytes;\nuse serde::{Deserialize, Serialize};\nuse "
},
{
"path": "crates/sshx-server/src/web/socket.rs",
"chars": 12175,
"preview": "use std::collections::HashSet;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse axum::extract::{\n ws::{CloseFr"
},
{
"path": "crates/sshx-server/src/web.rs",
"chars": 897,
"preview": "//! HTTP and WebSocket handlers for the sshx web interface.\n\nuse std::sync::Arc;\n\nuse axum::routing::{any, get_service};"
},
{
"path": "crates/sshx-server/tests/common/mod.rs",
"chars": 7243,
"preview": "use std::collections::{BTreeMap, HashMap};\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse a"
},
{
"path": "crates/sshx-server/tests/simple.rs",
"chars": 766,
"preview": "use anyhow::Result;\nuse sshx::encrypt::Encrypt;\nuse sshx_core::proto::*;\n\nuse crate::common::*;\n\npub mod common;\n\n#[toki"
},
{
"path": "crates/sshx-server/tests/snapshot.rs",
"chars": 1641,
"preview": "use std::sync::Arc;\n\nuse anyhow::Result;\nuse sshx::{controller::Controller, runner::Runner};\nuse sshx_core::{Sid, Uid};\n"
},
{
"path": "crates/sshx-server/tests/with_client.rs",
"chars": 8756,
"preview": "use anyhow::{Context, Result};\nuse sshx::{controller::Controller, encrypt::Encrypt, runner::Runner};\nuse sshx_core::{\n "
},
{
"path": "fly.toml",
"chars": 708,
"preview": "app = \"sshx\"\nprimary_region = \"ewr\"\nkill_signal = \"SIGINT\"\nkill_timeout = 90\n\n[experimental]\n auto_rollback = true\n cm"
},
{
"path": "mprocs.yaml",
"chars": 371,
"preview": "# prettier-ignore\nprocs:\n server:\n shell: >-\n cargo run --bin sshx-server --\n --override-origin http://loc"
},
{
"path": "package.json",
"chars": 1798,
"preview": "{\n \"name\": \"sshx\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite dev\",\n \"build\": \"vite buil"
},
{
"path": "postcss.config.cjs",
"chars": 479,
"preview": "const tailwindcss = require(\"tailwindcss\");\nconst autoprefixer = require(\"autoprefixer\");\nconst cssnano = require(\"cssna"
},
{
"path": "rustfmt.toml",
"chars": 155,
"preview": "unstable_features = true\ngroup_imports = \"StdExternalCrate\"\nwrap_comments = true\nformat_strings = true\nnormalize_comment"
},
{
"path": "scripts/release.sh",
"chars": 2773,
"preview": "#!/bin/bash\n\n# Manually releases the latest binaries to AWS S3.\n#\n# This runs on my M1 Macbook Pro with cross-compilatio"
},
{
"path": "src/app.css",
"chars": 656,
"preview": "@font-face {\n font-family: \"Fira Code VF\";\n src: url(\"firacode/distr/woff2/FiraCode-VF.woff2\") format(\"woff2-variation"
},
{
"path": "src/app.d.ts",
"chars": 421,
"preview": "/// <reference types=\"@sveltejs/kit\" />\n\n// Injected by vite.config.ts\ndeclare const __APP_VERSION__: string;\n\n// See ht"
},
{
"path": "src/app.html",
"chars": 988,
"preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"/favicon."
},
{
"path": "src/lib/Session.svelte",
"chars": 19142,
"preview": "<script lang=\"ts\">\n import {\n onDestroy,\n onMount,\n tick,\n beforeUpdate,\n afterUpdate,\n createEventDi"
},
{
"path": "src/lib/action/slide.ts",
"chars": 1843,
"preview": "import { tweened } from \"svelte/motion\";\nimport { cubicOut } from \"svelte/easing\";\nimport type { Action } from \"svelte/a"
},
{
"path": "src/lib/action/touchZoom.ts",
"chars": 8647,
"preview": "/**\n * @file Handles pan and zoom events to create an infinite canvas.\n *\n * This file is modified from Dispict <https:/"
},
{
"path": "src/lib/arrange.ts",
"chars": 1051,
"preview": "const ISECT_W = 752;\nconst ISECT_H = 515;\nconst ISECT_PAD = 16;\n\ntype ExistingTerminal = {\n x: number;\n y: number;\n w"
},
{
"path": "src/lib/encrypt.ts",
"chars": 2175,
"preview": "/**\n * @file Encryption of byte streams based on a random key.\n *\n * This is used for end-to-end encryption between the "
},
{
"path": "src/lib/lock.ts",
"chars": 747,
"preview": "// Simple async lock for use in streaming encryption.\n// See <https://stackoverflow.com/a/74538176>.\nexport function cre"
},
{
"path": "src/lib/protocol.ts",
"chars": 1137,
"preview": "type Sid = number; // u32\ntype Uid = number; // u32\n\n/** Position and size of a window, see the Rust version. */\nexport "
},
{
"path": "src/lib/settings.ts",
"chars": 1071,
"preview": "import { persisted } from \"svelte-persisted-store\";\nimport themes, { type ThemeName, defaultTheme } from \"./ui/themes\";\n"
},
{
"path": "src/lib/srocket.ts",
"chars": 3549,
"preview": "/**\n * @file Internal library for sshx, providing real-time communication.\n *\n * The contents of this file are technical"
},
{
"path": "src/lib/toast.ts",
"chars": 484,
"preview": "/** @file Provides a simple, native toast library. */\n\nimport { writable } from \"svelte/store\";\n\nexport const toastStore"
},
{
"path": "src/lib/typeahead.ts",
"chars": 52162,
"preview": "// A terminal \"local echo\" or typeahead addon for xterm.js.\n//\n// This is forked from VSCode's typeahead implementation "
},
{
"path": "src/lib/ui/Avatars.svelte",
"chars": 983,
"preview": "<script lang=\"ts\">\n import { fade } from \"svelte/transition\";\n\n import type { WsUser } from \"$lib/protocol\";\n import "
},
{
"path": "src/lib/ui/Chat.svelte",
"chars": 3218,
"preview": "<script lang=\"ts\" context=\"module\">\n export type ChatMessage = {\n uid: number;\n name: string;\n msg: string;\n "
},
{
"path": "src/lib/ui/ChooseName.svelte",
"chars": 885,
"preview": "<script lang=\"ts\">\n import { browser } from \"$app/environment\";\n\n import OverlayMenu from \"./OverlayMenu.svelte\";\n im"
},
{
"path": "src/lib/ui/CircleButton.svelte",
"chars": 651,
"preview": "<script lang=\"ts\">\n import { MinusIcon, PlusIcon, XIcon } from \"svelte-feather-icons\";\n\n export let kind: keyof typeof"
},
{
"path": "src/lib/ui/CircleButtons.svelte",
"chars": 84,
"preview": "<div class=\"flex space-x-2 text-transparent hover:text-black/75\">\n <slot />\n</div>\n"
},
{
"path": "src/lib/ui/CopyableCode.svelte",
"chars": 685,
"preview": "<script lang=\"ts\">\n import { CheckIcon, CopyIcon } from \"svelte-feather-icons\";\n\n export let value: string;\n\n let cop"
},
{
"path": "src/lib/ui/DownloadLink.svelte",
"chars": 415,
"preview": "<script lang=\"ts\">\n import { ExternalLinkIcon } from \"svelte-feather-icons\";\n\n export let href: string;\n</script>\n\n<a\n"
},
{
"path": "src/lib/ui/LiveCursor.svelte",
"chars": 1509,
"preview": "<script lang=\"ts\" context=\"module\">\n import type { WsUser } from \"$lib/protocol\";\n\n /** Convert a string into a unique"
},
{
"path": "src/lib/ui/NameList.svelte",
"chars": 803,
"preview": "<script lang=\"ts\">\n import { flip } from \"svelte/animate\";\n\n import type { WsUser } from \"$lib/protocol\";\n import { n"
},
{
"path": "src/lib/ui/NetworkInfo.svelte",
"chars": 2667,
"preview": "<script lang=\"ts\">\n import { fade } from \"svelte/transition\";\n\n export let status: \"connected\" | \"no-server\" | \"no-she"
},
{
"path": "src/lib/ui/OverlayMenu.svelte",
"chars": 1881,
"preview": "<script lang=\"ts\">\n import {\n Dialog,\n DialogDescription,\n DialogOverlay,\n DialogTitle,\n Transition,\n "
},
{
"path": "src/lib/ui/Settings.svelte",
"chars": 3468,
"preview": "<script lang=\"ts\">\n import { ChevronDownIcon } from \"svelte-feather-icons\";\n\n import { settings, updateSettings } from"
},
{
"path": "src/lib/ui/TeaserVideo.svelte",
"chars": 1910,
"preview": "<script lang=\"ts\">\n import logo from \"$lib/assets/logo.svg\";\n import {\n ArrowLeftIcon,\n ArrowRightIcon,\n Info"
},
{
"path": "src/lib/ui/Toast.svelte",
"chars": 1482,
"preview": "<script lang=\"ts\">\n import { createEventDispatcher } from \"svelte\";\n import {\n CheckCircleIcon,\n HelpCircleIcon,"
},
{
"path": "src/lib/ui/ToastContainer.svelte",
"chars": 1240,
"preview": "<script lang=\"ts\">\n import { onMount } from \"svelte\";\n import { flip } from \"svelte/animate\";\n import { fly } from \"s"
},
{
"path": "src/lib/ui/Toolbar.svelte",
"chars": 2228,
"preview": "<script lang=\"ts\">\n import { createEventDispatcher } from \"svelte\";\n import {\n MessageSquareIcon,\n PlusCircleIco"
},
{
"path": "src/lib/ui/XTerm.svelte",
"chars": 8569,
"preview": "<!-- @component Interactive terminal rendered with xterm.js -->\n<script lang=\"ts\" context=\"module\">\n import { makeToast"
},
{
"path": "src/lib/ui/themes.ts",
"chars": 4763,
"preview": "import type { ITheme } from \"sshx-xterm\";\n\n/** VSCode default dark theme, from https://glitchbone.github.io/vscode-base1"
},
{
"path": "src/routes/+error.svelte",
"chars": 1094,
"preview": "<script lang=\"ts\">\n import { page } from \"$app/stores\";\n\n import logotypeDark from \"$lib/assets/logotype-dark.svg\";\n</"
},
{
"path": "src/routes/+layout.svelte",
"chars": 222,
"preview": "<script lang=\"ts\">\n import \"@fontsource-variable/inter\";\n\n import \"sshx-xterm/css/xterm.css\";\n import \"../app.css\";\n\n"
},
{
"path": "src/routes/+page.svelte",
"chars": 10049,
"preview": "<script lang=\"ts\">\n import {\n CastIcon,\n DownloadIcon,\n GitBranchIcon,\n HardDriveIcon,\n ImageIcon,\n L"
},
{
"path": "src/routes/+page.ts",
"chars": 31,
"preview": "export const prerender = true;\n"
},
{
"path": "src/routes/s/[id]/+page.svelte",
"chars": 447,
"preview": "<script lang=\"ts\">\n import { page } from \"$app/stores\";\n\n import Session from \"$lib/Session.svelte\";\n\n let title: str"
},
{
"path": "static/get",
"chars": 2305,
"preview": "#!/bin/sh\n\n# This is a short script to install the latest version of the sshx binary.\n#\n# It's meant to be as simple as "
},
{
"path": "svelte.config.js",
"chars": 466,
"preview": "import adapter from \"@sveltejs/adapter-static\";\nimport preprocess from \"svelte-preprocess\";\n\n/** @type {import('@sveltej"
},
{
"path": "tailwind.config.cjs",
"chars": 432,
"preview": "const defaultTheme = require(\"tailwindcss/defaultTheme\");\n\n/** @type {import(\"tailwindcss\").Config} */\nconst config = {\n"
},
{
"path": "tsconfig.json",
"chars": 94,
"preview": "{\n \"extends\": \"./.svelte-kit/tsconfig.json\",\n \"compilerOptions\": {\n \"strict\": true\n }\n}\n"
},
{
"path": "vite.config.ts",
"chars": 496,
"preview": "import { execSync } from \"node:child_process\";\n\nimport { defineConfig } from \"vite\";\nimport { sveltekit } from \"@sveltej"
}
]
About this extraction
This page contains the full source code of the ekzhang/sshx GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 90 files (288.6 KB), approximately 77.4k tokens, and a symbol index with 391 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.