Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add read-only mode for terminal sessions #104

Merged
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions crates/sshx-core/proto/sshx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ message TerminalSize {

// 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).
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.
Expand Down Expand Up @@ -103,6 +104,7 @@ message SerializedSession {
uint32 next_sid = 3;
uint32 next_uid = 4;
string name = 5;
optional bytes write_password_hash = 6;
}

message SerializedShell {
Expand Down
1 change: 1 addition & 0 deletions crates/sshx-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ redis = { version = "0.23.3", features = ["tokio-rustls-comp", "tls-rustls-webpk
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.20.0"
Expand Down
2 changes: 2 additions & 0 deletions crates/sshx-server/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ impl SshxService for GrpcServer {
}
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)));
}
Expand Down
16 changes: 15 additions & 1 deletion crates/sshx-server/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub struct Metadata {

/// 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.
Expand Down Expand Up @@ -307,7 +310,7 @@ impl Session {
}

/// Add a new user, and return a guard that removes the user when dropped.
pub fn user_scope(&self, id: Uid) -> Result<impl Drop + '_> {
pub fn user_scope(&self, id: Uid, can_write: bool) -> Result<impl Drop + '_> {
use std::collections::hash_map::Entry::*;

#[must_use]
Expand All @@ -325,6 +328,7 @@ impl Session {
name: format!("User {id}"),
cursor: None,
focus: None,
can_write,
};
v.insert(user.clone());
self.broadcast.send(WsServer::UserDiff(id, Some(user))).ok();
Expand All @@ -341,6 +345,16 @@ impl Session {
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.
Expand Down
3 changes: 3 additions & 0 deletions crates/sshx-server/src/session/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl Session {
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");
Expand All @@ -72,9 +73,11 @@ impl Session {
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);
Expand Down
7 changes: 5 additions & 2 deletions crates/sshx-server/src/web/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub struct WsUser {
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.
Expand Down Expand Up @@ -71,8 +73,9 @@ pub enum WsServer {
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub enum WsClient {
/// Authenticate the user's encryption key by zeros block.
Authenticate(Bytes),
/// 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.
Expand Down
50 changes: 45 additions & 5 deletions crates/sshx-server/src/web/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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};
Expand Down Expand Up @@ -95,15 +96,38 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
session.sync_now();
send(socket, WsServer::Hello(user_id, metadata.name.clone())).await?;

match recv(socket).await? {
Some(WsClient::Authenticate(bytes)) if bytes == metadata.encrypted_zeros => {}
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?;
ekzhang marked this conversation as resolved.
Show resolved Hide resolved
return Ok(());
}
true
}
}
}
_ => {
send(socket, WsServer::InvalidAuth()).await?;
return Ok(());
}
}
};

let _user_guard = session.user_scope(user_id)?;
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();
Expand Down Expand Up @@ -138,7 +162,7 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
};

match msg {
WsClient::Authenticate(_) => {}
WsClient::Authenticate(_, _) => {}
WsClient::SetName(name) => {
if !name.is_empty() {
session.update_user(user_id, |user| user.name = name)?;
Expand All @@ -151,6 +175,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
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 };
Expand All @@ -159,9 +187,17 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
.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;
Expand All @@ -176,6 +212,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
}
}
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,
Expand Down
9 changes: 7 additions & 2 deletions crates/sshx-server/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ impl Drop for TestServer {
pub struct ClientSocket {
inner: WebSocketStream<MaybeTlsStream<TcpStream>>,
encrypt: Encrypt,
write_encrypt: Option<Encrypt>,

pub user_id: Uid,
pub users: BTreeMap<Uid, WsUser>,
Expand All @@ -93,13 +94,14 @@ pub struct ClientSocket {

impl ClientSocket {
/// Connect to a WebSocket endpoint.
pub async fn connect(uri: &str, key: &str) -> Result<Self> {
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(),
Expand All @@ -113,7 +115,10 @@ impl ClientSocket {

async fn authenticate(&mut self) {
let encrypted_zeros = self.encrypt.zeros().into();
self.send(WsClient::Authenticate(encrypted_zeros)).await;
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) {
Expand Down
1 change: 1 addition & 0 deletions crates/sshx-server/tests/simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async fn test_rpc() -> Result<()> {
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());
Expand Down
6 changes: 3 additions & 3 deletions crates/sshx-server/tests/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ pub mod common;
async fn test_basic_restore() -> Result<()> {
let server = TestServer::new().await;

let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo).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).await?;
let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;
s.flush().await;
assert_eq!(s.user_id, Uid(1));

Expand All @@ -47,7 +47,7 @@ async fn test_basic_restore() -> Result<()> {
.state()
.insert(&name, Arc::new(Session::restore(&data)?));

let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key).await?;
let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;
s.send(WsClient::Subscribe(Sid(1), 0)).await;
s.flush().await;

Expand Down
Loading
Loading