diff options
| author | user <user@clank> | 2026-08-12 19:34:15 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:15 +0200 |
| commit | d1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch) | |
| tree | 9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-client/src/remote_attach | |
| parent | chore: initialize sl0ppi monorepo (diff) | |
| download | sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.gz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.xz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.zip | |
vendor(sl0ppij): import zellij v0.45.0 tree
Squashed import of upstream zellij at 0e6e4404 ("Support the Kitty Image
Protocol (#5428)"), the merge-base our fork was developed against. Upstream's
3311 commits are deliberately NOT replayed here; only our own fork commits are,
so the monorepo stays small and `git log sl0ppij/` shows sl0p work, not zellij's
entire history.
Upstream provenance is tracked in FORKS.md and can always be re-fetched from
https://github.com/zellij-org/zellij.
Diffstat (limited to 'sl0ppij/zellij-client/src/remote_attach')
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/auth.rs | 149 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/config.rs | 14 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/http_client.rs | 122 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/mod.rs | 267 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/unit/mod.rs | 1 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/unit/remote_attach_tests.rs | 960 | ||||
| -rw-r--r-- | sl0ppij/zellij-client/src/remote_attach/websockets.rs | 278 |
7 files changed, 1791 insertions, 0 deletions
diff --git a/sl0ppij/zellij-client/src/remote_attach/auth.rs b/sl0ppij/zellij-client/src/remote_attach/auth.rs new file mode 100644 index 0000000..b995580 --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/auth.rs @@ -0,0 +1,149 @@ +use super::config::{LOGIN_ENDPOINT, SESSION_ENDPOINT}; +use super::http_client::HttpClientWithCookies; +use crate::RemoteClientError; +use isahc::{AsyncReadResponseExt, Request}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct LoginRequest { + auth_token: String, + remember_me: bool, +} + +#[derive(Deserialize)] +pub struct SessionResponse { + pub web_client_id: String, +} + +pub async fn authenticate( + server_base_url: &str, + auth_token: &str, + remember_me: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<(String, HttpClientWithCookies, Option<String>), RemoteClientError> { + let http_client = HttpClientWithCookies::new(ca_cert, insecure) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; + + // Step 1: Login with auth token + let login_url = format!("{}{}", server_base_url, LOGIN_ENDPOINT); + + let login_request = LoginRequest { + auth_token: auth_token.to_string(), + remember_me, + }; + + let response = http_client + .send_with_cookies( + Request::post(login_url) + .header("Content-Type", "application/json") + .header("User-Agent", "http-terminal-client/1.0") + .header("Accept", "application/json") + .body( + serde_json::to_vec(&login_request) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?, + ) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?, + ) + .await + .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; + + // Handle HTTP status codes + match response.status().as_u16() { + 401 => return Err(RemoteClientError::InvalidAuthToken), + status if !response.status().is_success() => { + return Err(RemoteClientError::ConnectionFailed(format!( + "Server returned status {}", + status + ))); + }, + _ => {}, + } + + // Step 2: Get session/client ID + let session_url = format!("{}{}", server_base_url, SESSION_ENDPOINT); + + let mut session_response = http_client + .send_with_cookies( + Request::post(session_url) + .header("Content-Type", "application/json") + .header("User-Agent", "http-terminal-client/1.0") + .header("Accept", "application/json") + .body("{}".as_bytes().to_vec()) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?, + ) + .await + .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; + + // Handle session response + match session_response.status().as_u16() { + 401 => return Err(RemoteClientError::Unauthorized), + status if !session_response.status().is_success() => { + return Err(RemoteClientError::ConnectionFailed(format!( + "Server returned status {}", + status + ))); + }, + _ => {}, + } + + let response_body = session_response + .text() + .await + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; + let session_data: SessionResponse = + serde_json::from_str(&response_body).map_err(|e| RemoteClientError::Other(Box::new(e)))?; + + // Extract session_token if remember_me was true + let session_token = if remember_me { + http_client.get_cookie("session_token") + } else { + None + }; + + Ok((session_data.web_client_id, http_client, session_token)) +} + +pub async fn validate_session_token( + server_base_url: &str, + session_token: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<(String, HttpClientWithCookies), RemoteClientError> { + let http_client = HttpClientWithCookies::new(ca_cert, insecure) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; + + // Pre-populate the session_token cookie + http_client.set_cookie("session_token".to_string(), session_token.to_string()); + + // Skip /login, go directly to /session endpoint + let session_url = format!("{}{}", server_base_url, SESSION_ENDPOINT); + + let mut session_response = http_client + .send_with_cookies( + Request::post(session_url) + .header("Content-Type", "application/json") + .header("User-Agent", "http-terminal-client/1.0") + .header("Accept", "application/json") + .body("{}".as_bytes().to_vec()) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?, + ) + .await + .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; + + match session_response.status().as_u16() { + 401 => Err(RemoteClientError::SessionTokenExpired), + status if !session_response.status().is_success() => Err( + RemoteClientError::ConnectionFailed(format!("Server returned status {}", status)), + ), + _ => { + let response_body = session_response + .text() + .await + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; + let session_data: SessionResponse = serde_json::from_str(&response_body) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; + Ok((session_data.web_client_id, http_client)) + }, + } +} diff --git a/sl0ppij/zellij-client/src/remote_attach/config.rs b/sl0ppij/zellij-client/src/remote_attach/config.rs new file mode 100644 index 0000000..4b10fbb --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/config.rs @@ -0,0 +1,14 @@ +use std::time::Duration; + +// API endpoints +pub const LOGIN_ENDPOINT: &str = "/command/login"; +pub const SESSION_ENDPOINT: &str = "/session"; +pub const WS_TERMINAL_ENDPOINT: &str = "/ws/terminal"; +pub const WS_CONTROL_ENDPOINT: &str = "/ws/control"; + +// Connection settings +pub const CONNECTION_TIMEOUT_SECS: u64 = 30; + +pub fn connection_timeout() -> Duration { + Duration::from_secs(CONNECTION_TIMEOUT_SECS) +} diff --git a/sl0ppij/zellij-client/src/remote_attach/http_client.rs b/sl0ppij/zellij-client/src/remote_attach/http_client.rs new file mode 100644 index 0000000..5fa66b6 --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/http_client.rs @@ -0,0 +1,122 @@ +use super::config::connection_timeout; +use isahc::prelude::*; +use isahc::{config::RedirectPolicy, AsyncBody, HttpClient, Request, Response}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +pub fn create_http_client( + ca_cert: Option<&Path>, + insecure: bool, +) -> Result<HttpClient, isahc::Error> { + let mut builder = HttpClient::builder() + .redirect_policy(RedirectPolicy::Follow) + .timeout(connection_timeout()); + + if insecure { + eprintln!( + "WARNING: TLS certificate validation is disabled. This connection is NOT secure." + ); + builder = builder.ssl_options( + isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS + | isahc::config::SslOption::DANGER_ACCEPT_INVALID_HOSTS, + ); + } else if let Some(ca_path) = ca_cert { + builder = builder.ssl_ca_certificate(isahc::config::CaCertificate::file(ca_path)); + } + + builder.build() +} + +pub struct HttpClientWithCookies { + client: HttpClient, + cookies: Arc<Mutex<HashMap<String, String>>>, +} + +impl HttpClientWithCookies { + pub fn new(ca_cert: Option<&Path>, insecure: bool) -> Result<Self, isahc::Error> { + Ok(Self { + client: create_http_client(ca_cert, insecure)?, + cookies: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub async fn send_with_cookies<T: Into<Request<Vec<u8>>>>( + &self, + request: T, + ) -> Result<Response<AsyncBody>, isahc::Error> { + let mut req = request.into(); + + // Add cookies to request + if let Ok(cookies) = self.cookies.lock() { + if !cookies.is_empty() { + let cookie_header = cookies + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::<Vec<_>>() + .join("; "); + req.headers_mut() + .insert("cookie", cookie_header.parse().unwrap()); + } + } + + let response = self.client.send_async(req).await?; + + // Extract and store cookies from response + if let Some(set_cookie_headers) = response.headers().get_all("set-cookie").iter().next() { + if let Ok(cookie_str) = set_cookie_headers.to_str() { + self.parse_and_store_cookies(cookie_str); + } + } + + Ok(response) + } + + fn parse_and_store_cookies(&self, cookie_header: &str) { + if let Ok(mut cookies) = self.cookies.lock() { + // Simple cookie parsing - just extract name=value pairs + for cookie_part in cookie_header.split(';') { + let cookie_part = cookie_part.trim(); + if let Some((name, value)) = cookie_part.split_once('=') { + // Skip cookie attributes like Path, Domain, HttpOnly, etc. + if ![ + "path", "domain", "httponly", "secure", "samesite", "expires", "max-age", + ] + .contains(&name.to_lowercase().as_str()) + { + cookies.insert(name.trim().to_string(), value.trim().to_string()); + } + } + } + } + } + + pub fn get_cookie_header(&self) -> Option<String> { + if let Ok(cookies) = self.cookies.lock() { + if !cookies.is_empty() { + let cookie_header = cookies + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::<Vec<_>>() + .join("; "); + return Some(cookie_header); + } + } + None + } + + /// Extract a specific cookie value + pub fn get_cookie(&self, name: &str) -> Option<String> { + if let Ok(cookies) = self.cookies.lock() { + return cookies.get(name).cloned(); + } + None + } + + /// Pre-populate a cookie (for saved session tokens) + pub fn set_cookie(&self, name: String, value: String) { + if let Ok(mut cookies) = self.cookies.lock() { + cookies.insert(name, value); + } + } +} diff --git a/sl0ppij/zellij-client/src/remote_attach/mod.rs b/sl0ppij/zellij-client/src/remote_attach/mod.rs new file mode 100644 index 0000000..26228cf --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/mod.rs @@ -0,0 +1,267 @@ +mod auth; +mod config; +pub mod http_client; +pub mod websockets; + +#[cfg(test)] +mod unit; + +pub use websockets::WebSocketConnections; + +use crate::os_input_output::ClientOsApi; +use crate::RemoteClientError; +use tokio::runtime::Handle; +use zellij_utils::remote_session_tokens; + +// In tests, only attempt once (no retries) to avoid interactive prompts +// In production, allow up to 3 attempts (initial + 2 retries) +#[cfg(test)] +const MAX_AUTH_ATTEMPTS: u32 = 1; + +#[cfg(not(test))] +const MAX_AUTH_ATTEMPTS: u32 = 3; + +/// Attach to a remote Zellij session via HTTP(S) +/// +/// This function handles the complete authentication flow including: +/// - URL validation +/// - Session token management (--forget, --token flags) +/// - Trying saved session tokens +/// - Interactive authentication with retry logic +/// - Saving session tokens when --remember is used +/// +/// Returns WebSocketConnections on success +pub fn attach_to_remote_session( + runtime: Handle, + _os_input: Box<dyn ClientOsApi>, + remote_session_url: &str, + token: Option<String>, + remember: bool, + forget: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<WebSocketConnections, RemoteClientError> { + // Extract server URL for token management + let server_url = extract_server_url(remote_session_url)?; + + // Handle --forget flag + if forget { + let _ = remote_session_tokens::delete_session_token(&server_url); + } + + // If --token provided, delete saved session token + if token.is_some() { + let _ = remote_session_tokens::delete_session_token(&server_url); + } + + if token.is_none() { + if let Some(connections) = try_to_connect_with_saved_session_token( + runtime.clone(), + remote_session_url, + &server_url, + ca_cert, + insecure, + )? { + return Ok(connections); + } + } + + // Normal auth flow with retry logic + authenticate_with_retry( + runtime, + remote_session_url, + token, + remember, + ca_cert, + insecure, + ) +} + +/// Try to connect using a saved session token +/// Returns Ok(Some(connections)) on success, Ok(None) if should retry with auth +fn try_to_connect_with_saved_session_token( + runtime: Handle, + remote_session_url: &str, + server_url: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<Option<WebSocketConnections>, RemoteClientError> { + if let Ok(Some(saved_session_token)) = remote_session_tokens::get_session_token(server_url) { + // we have a saved session token, let's try to authenticate with it + let ca_cert_owned = ca_cert.map(|p| p.to_path_buf()); + match runtime.block_on(async move { + remote_attach_with_session_token( + remote_session_url, + &saved_session_token, + ca_cert_owned.as_deref(), + insecure, + ) + .await + }) { + Ok(connections) => { + return Ok(Some(connections)); + }, + Err(RemoteClientError::SessionTokenExpired) => { + // Session expired - delete and return to retry + let _ = remote_session_tokens::delete_session_token(server_url); + eprintln!("Session expired, please re-authenticate"); + return Ok(None); + }, + Err(e) => { + return Err(e); + }, + } + } + Ok(None) +} + +fn dialoguer_error_to_client_error(error: dialoguer::Error) -> RemoteClientError { + match error { + dialoguer::Error::IO(error) => RemoteClientError::IoError(error), + } +} + +fn authenticate_with_retry( + runtime: Handle, + remote_session_url: &str, + initial_token: Option<String>, + remember: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<WebSocketConnections, RemoteClientError> { + use dialoguer::{Confirm, Password}; + + let mut attempt = 0; + let mut current_token = initial_token; + + loop { + attempt += 1; + + let auth_token = match ¤t_token { + Some(t) => t.clone(), + None => Password::new() + .with_prompt("Enter authentication token") + .interact() + .map_err(dialoguer_error_to_client_error)?, + }; + + let ca_cert_owned = ca_cert.map(|p| p.to_path_buf()); + match runtime.block_on(async move { + remote_attach( + remote_session_url, + &auth_token, + remember, + ca_cert_owned.as_deref(), + insecure, + ) + .await + }) { + Ok((connections, session_token_opt)) => { + // Save session token if we got one + if let Some(session_token) = session_token_opt { + let server_url = extract_server_url(remote_session_url)?; + let _ = remote_session_tokens::save_session_token(&server_url, &session_token); + } + return Ok(connections); + }, + Err(RemoteClientError::InvalidAuthToken) => { + eprintln!("Invalid authentication token"); + + if attempt >= MAX_AUTH_ATTEMPTS { + eprintln!( + "Maximum authentication attempts ({}) exceeded.", + MAX_AUTH_ATTEMPTS + ); + return Err(RemoteClientError::InvalidAuthToken); + } + + match Confirm::new() + .with_prompt("Try again?") + .default(true) + .interact() + { + Ok(true) => { + current_token = None; + continue; + }, + Ok(false) => { + return Err(RemoteClientError::InvalidAuthToken); + }, + Err(e) => { + return Err(dialoguer_error_to_client_error(e)); + }, + } + }, + Err(e) => { + return Err(e); + }, + } + } +} + +async fn remote_attach( + server_url: &str, + auth_token: &str, + remember_me: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<(websockets::WebSocketConnections, Option<String>), RemoteClientError> { + let server_base_url = extract_server_url(server_url)?; + let session_name = extract_session_name(server_url)?; + let (web_client_id, http_client, session_token) = + auth::authenticate(&server_base_url, auth_token, remember_me, ca_cert, insecure).await?; + let connections = websockets::establish_websocket_connections( + &web_client_id, + &http_client, + &server_base_url, + &session_name, + ca_cert, + insecure, + ) + .await + .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; + Ok((connections, session_token)) +} + +async fn remote_attach_with_session_token( + server_url: &str, + session_token: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, +) -> Result<websockets::WebSocketConnections, RemoteClientError> { + let server_base_url = extract_server_url(server_url)?; + let session_name = extract_session_name(server_url)?; + let (web_client_id, http_client) = + auth::validate_session_token(&server_base_url, session_token, ca_cert, insecure).await?; + let connections = websockets::establish_websocket_connections( + &web_client_id, + &http_client, + &server_base_url, + &session_name, + ca_cert, + insecure, + ) + .await + .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; + Ok(connections) +} + +pub fn extract_server_url(full_url: &str) -> Result<String, RemoteClientError> { + let parsed = url::Url::parse(full_url)?; + let mut base_url = parsed.clone(); + base_url.set_path(""); + base_url.set_query(None); + base_url.set_fragment(None); + Ok(base_url.to_string().trim_end_matches('/').to_string()) +} + +fn extract_session_name(server_url: &str) -> Result<String, RemoteClientError> { + let parsed_url = url::Url::parse(server_url)?; + let path = parsed_url.path(); + // Extract session name from path (everything after the first /) + if path.len() > 1 && path.starts_with('/') { + Ok(path[1..].trim_end_matches('/').to_string()) + } else { + Ok(String::new()) + } +} diff --git a/sl0ppij/zellij-client/src/remote_attach/unit/mod.rs b/sl0ppij/zellij-client/src/remote_attach/unit/mod.rs new file mode 100644 index 0000000..1f2fbe8 --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/unit/mod.rs @@ -0,0 +1 @@ +mod remote_attach_tests; diff --git a/sl0ppij/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/sl0ppij/zellij-client/src/remote_attach/unit/remote_attach_tests.rs new file mode 100644 index 0000000..1210ac1 --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/unit/remote_attach_tests.rs @@ -0,0 +1,960 @@ +use super::super::*; +use crate::RemoteClientError; +use serial_test::serial; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use zellij_utils::remote_session_tokens; + +// Mock server infrastructure +#[cfg(feature = "web_server_capability")] +mod mock_server { + use super::*; + use axum::{ + extract::State, + http::StatusCode, + response::Response, + routing::{get, post}, + Json, Router, + }; + use axum_extra::extract::cookie::{Cookie, CookieJar}; + use serde::Deserialize; + use serde_json::json; + use tokio::net::TcpListener; + use uuid::Uuid; + + #[derive(Clone)] + pub struct MockRemoteServerState { + pub valid_auth_tokens: Arc<Mutex<HashMap<String, ()>>>, + pub session_tokens: Arc<Mutex<HashMap<String, String>>>, // token -> web_client_id + pub endpoints_called: Arc<Mutex<Vec<String>>>, + } + + impl MockRemoteServerState { + pub fn new() -> Self { + Self { + valid_auth_tokens: Arc::new(Mutex::new(HashMap::new())), + session_tokens: Arc::new(Mutex::new(HashMap::new())), + endpoints_called: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn add_valid_token(&self, token: &str) { + self.valid_auth_tokens + .lock() + .unwrap() + .insert(token.to_string(), ()); + } + + fn record_endpoint(&self, endpoint: &str) { + self.endpoints_called + .lock() + .unwrap() + .push(endpoint.to_string()); + } + + pub fn get_endpoints_called(&self) -> Vec<String> { + self.endpoints_called.lock().unwrap().clone() + } + } + + #[derive(Deserialize)] + pub struct LoginRequest { + pub auth_token: String, + } + + pub async fn handle_login( + State(state): State<MockRemoteServerState>, + jar: CookieJar, + Json(payload): Json<LoginRequest>, + ) -> Result<(CookieJar, Json<serde_json::Value>), StatusCode> { + state.record_endpoint("/command/login"); + + let valid_tokens = state.valid_auth_tokens.lock().unwrap(); + if !valid_tokens.contains_key(&payload.auth_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(valid_tokens); + + // Always create a session token (cookie is always set) + let session_token = Uuid::new_v4().to_string(); + let web_client_id = Uuid::new_v4().to_string(); + + state + .session_tokens + .lock() + .unwrap() + .insert(session_token.clone(), web_client_id); + + let cookie = Cookie::build(("session_token", session_token)) + .path("/") + .http_only(true) + .build(); + let jar = jar.add(cookie); + + Ok(( + jar, + Json(json!({ + "success": true, + "message": "Login successful" + })), + )) + } + + pub async fn handle_session( + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Json<serde_json::Value>, StatusCode> { + state.record_endpoint("/session"); + + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + let web_client_id = session_tokens + .get(&session_token) + .ok_or(StatusCode::UNAUTHORIZED)? + .clone(); + drop(session_tokens); + + Ok(Json(json!({ + "web_client_id": web_client_id + }))) + } + + pub async fn handle_ws_terminal( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Response, StatusCode> { + state.record_endpoint("/ws/terminal"); + + // Validate session token + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + if !session_tokens.contains_key(&session_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(session_tokens); + + Ok(ws.on_upgrade(|socket| async move { + // Basic echo WebSocket handler + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt}; + let (mut sender, mut receiver) = socket.split(); + + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + let _ = sender.send(Message::Text(text)).await; + } + } + })) + } + + pub async fn handle_ws_control( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Response, StatusCode> { + state.record_endpoint("/ws/control"); + + // Validate session token + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + if !session_tokens.contains_key(&session_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(session_tokens); + + Ok(ws.on_upgrade(|socket| async move { + // Basic echo WebSocket handler + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt}; + let (mut sender, mut receiver) = socket.split(); + + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + let _ = sender.send(Message::Text(text)).await; + } + } + })) + } + + pub async fn start_mock_server( + state: MockRemoteServerState, + ) -> (u16, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/command/login", post(handle_login)) + .route("/session", post(handle_session)) + .route("/ws/terminal", get(handle_ws_terminal)) + .route("/ws/terminal/{session_name}", get(handle_ws_terminal)) + .route("/ws/control", get(handle_ws_control)) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server_handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + // Wait for server to be ready + tokio::time::sleep(Duration::from_millis(100)).await; + + (port, server_handle) + } +} + +#[cfg(feature = "web_server_capability")] +mod tls_mock_server { + use super::mock_server::MockRemoteServerState; + use axum::routing::{get, post}; + use axum::Router; + use axum_server::tls_rustls::RustlsConfig; + use axum_server::Handle; + use std::net::SocketAddr; + use std::path::PathBuf; + use std::time::Duration; + + pub struct TlsTestCerts { + pub ca_cert_path: PathBuf, + _ca_cert_file: tempfile::NamedTempFile, + _server_cert_file: tempfile::NamedTempFile, + _server_key_file: tempfile::NamedTempFile, + server_cert_path: PathBuf, + server_key_path: PathBuf, + } + + pub fn generate_test_certs() -> TlsTestCerts { + // Create a CA with proper key usage + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + let ca = ca_params.self_signed(&ca_key).unwrap(); + + // Create server cert with IP SAN only (no DNS name for IP addresses) + let mut server_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap(); + server_params.subject_alt_names = vec![rcgen::SanType::IpAddress(std::net::IpAddr::V4( + std::net::Ipv4Addr::LOCALHOST, + ))]; + server_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let server_key = rcgen::KeyPair::generate().unwrap(); + let ca_issuer = rcgen::Issuer::from_params(&ca_params, &ca_key); + let server_cert = server_params.signed_by(&server_key, &ca_issuer).unwrap(); + + // Write to temp files + let ca_cert_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(ca_cert_file.path(), ca.pem()).unwrap(); + + let server_cert_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(server_cert_file.path(), server_cert.pem()).unwrap(); + + let server_key_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(server_key_file.path(), server_key.serialize_pem()).unwrap(); + + TlsTestCerts { + ca_cert_path: ca_cert_file.path().to_path_buf(), + server_cert_path: server_cert_file.path().to_path_buf(), + server_key_path: server_key_file.path().to_path_buf(), + _ca_cert_file: ca_cert_file, + _server_cert_file: server_cert_file, + _server_key_file: server_key_file, + } + } + + pub async fn start_tls_mock_server( + state: MockRemoteServerState, + certs: &TlsTestCerts, + ) -> (u16, Handle<SocketAddr>, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/command/login", post(super::mock_server::handle_login)) + .route("/session", post(super::mock_server::handle_session)) + .route("/ws/terminal", get(super::mock_server::handle_ws_terminal)) + .route( + "/ws/terminal/{session_name}", + get(super::mock_server::handle_ws_terminal), + ) + .route("/ws/control", get(super::mock_server::handle_ws_control)) + .with_state(state); + + let rustls_config = + RustlsConfig::from_pem_file(&certs.server_cert_path, &certs.server_key_path) + .await + .expect("Failed to load test TLS config"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind test TLS server"); + listener + .set_nonblocking(true) + .expect("Failed to set test TLS server listener to non-blocking"); + let port = listener.local_addr().unwrap().port(); + + let handle = Handle::new(); + let server_handle = handle.clone(); + + let server_task = tokio::spawn(async move { + axum_server::from_tcp_rustls(listener, rustls_config) + .unwrap() + .handle(server_handle) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + // Wait for the server to be listening (deterministic, no sleep) + handle.listening().await; + + (port, handle, server_task) + } + + pub async fn shutdown_server( + handle: Handle<SocketAddr>, + server_task: tokio::task::JoinHandle<()>, + ) { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server_task.await; + } +} + +// Database test helpers +fn setup_test_db(server_url: &str) { + let _ = remote_session_tokens::delete_session_token(server_url); +} + +fn cleanup_test_db(server_url: &str) { + let _ = remote_session_tokens::delete_session_token(server_url); +} + +// Mock ClientOsApi for testing +#[derive(Debug, Clone)] +struct MockClientOsApi; + +impl crate::os_input_output::ClientOsApi for MockClientOsApi { + fn get_terminal_size(&self) -> zellij_utils::pane_size::Size { + zellij_utils::pane_size::Size { rows: 24, cols: 80 } + } + + fn set_raw_mode(&mut self) {} + + fn unset_raw_mode(&self) -> Result<(), std::io::Error> { + Ok(()) + } + + fn box_clone(&self) -> Box<dyn crate::os_input_output::ClientOsApi> { + Box::new(MockClientOsApi) + } + + fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> { + Ok(Vec::new()) + } + + fn get_stdin_reader(&self) -> Box<dyn std::io::BufRead> { + Box::new(std::io::BufReader::new(std::io::empty())) + } + + fn get_stdout_writer(&self) -> Box<dyn std::io::Write> { + Box::new(std::io::sink()) + } + + fn update_session_name(&mut self, _new_session_name: String) {} + + fn send_to_server(&self, _msg: zellij_utils::ipc::ClientToServerMsg) {} + + fn recv_from_server( + &self, + ) -> Option<( + zellij_utils::ipc::ServerToClientMsg, + zellij_utils::errors::ErrorContext, + )> { + None + } + + fn handle_signals( + &self, + _sigwinch_cb: Box<dyn Fn()>, + _quit_cb: Box<dyn Fn()>, + _resize_receiver: Option<std::sync::mpsc::Receiver<()>>, + ) { + } + + fn connect_to_server(&self, _path: &std::path::Path) {} + + fn load_palette(&self) -> zellij_utils::data::Palette { + zellij_utils::shared::default_palette() + } + + fn enable_mouse(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn disable_mouse(&self) -> anyhow::Result<()> { + Ok(()) + } +} + +// Tests +#[cfg(feature = "web_server_capability")] +mod tests { + use super::mock_server::*; + use super::*; + + // Helper function to call attach_to_remote_session from async context + async fn call_attach_to_remote_session( + remote_session_url: String, + token: Option<String>, + remember: bool, + forget: bool, + ) -> Result<WebSocketConnections, RemoteClientError> { + tokio::task::spawn_blocking(move || { + let runtime = crate::async_runtime(None); + let os_input: Box<dyn crate::os_input_output::ClientOsApi> = Box::new(MockClientOsApi); + attach_to_remote_session( + runtime, + os_input, + &remote_session_url, + token, + remember, + forget, + None, + true, // insecure for tests + ) + }) + .await + .unwrap() + } + + #[tokio::test] + #[serial] + async fn test_successful_authentication_with_valid_token() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-auth-token-123"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + + setup_test_db(&format!("http://127.0.0.1:{}", port)); + + let result = + call_attach_to_remote_session(server_url, Some(auth_token.to_string()), false, false) + .await; + + assert!( + result.is_ok(), + "Should successfully authenticate: {:?}", + result.err() + ); + + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/command/login".to_string()), + "Should call login endpoint" + ); + assert!( + endpoints.contains(&"/session".to_string()), + "Should call session endpoint" + ); + assert!( + endpoints.contains(&"/ws/terminal".to_string()), + "Should establish terminal WebSocket" + ); + assert!( + endpoints.contains(&"/ws/control".to_string()), + "Should establish control WebSocket" + ); + + server_handle.abort(); + cleanup_test_db(&format!("http://127.0.0.1:{}", port)); + } + + #[tokio::test] + #[serial] + async fn test_failed_authentication_with_invalid_token() { + let server_state = MockRemoteServerState::new(); + // Don't add the token to valid tokens - server will reject it + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + + setup_test_db(&format!("http://127.0.0.1:{}", port)); + + let result = call_attach_to_remote_session( + server_url, + Some("invalid-token".to_string()), + false, + false, + ) + .await; + + assert!(result.is_err(), "Should fail with invalid token"); + assert!( + matches!(result.unwrap_err(), RemoteClientError::InvalidAuthToken), + "Should return InvalidAuthToken error" + ); + + server_handle.abort(); + cleanup_test_db(&format!("http://127.0.0.1:{}", port)); + } + + #[tokio::test] + #[serial] + async fn test_save_session_token_with_remember_true() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-token-remember"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), + true, // remember = true + false, + ) + .await; + + assert!(result.is_ok(), "Connection should succeed"); + + // Verify token was saved + let saved_token = remote_session_tokens::get_session_token(&base_url); + assert!(saved_token.is_ok()); + assert!( + saved_token.unwrap().is_some(), + "Session token should be saved" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_dont_save_token_with_remember_false() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-token-no-remember"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), + false, // remember = false + false, + ) + .await; + + assert!(result.is_ok(), "Connection should succeed"); + + // Verify token was NOT saved + let saved_token = remote_session_tokens::get_session_token(&base_url); + assert!(saved_token.is_ok()); + assert!( + saved_token.unwrap().is_none(), + "Session token should NOT be saved" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_load_and_use_saved_session_token() { + let server_state = MockRemoteServerState::new(); + + // Pre-create a session token + let session_token = uuid::Uuid::new_v4().to_string(); + let web_client_id = uuid::Uuid::new_v4().to_string(); + server_state + .session_tokens + .lock() + .unwrap() + .insert(session_token.clone(), web_client_id); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + // Save the session token + remote_session_tokens::save_session_token(&base_url, &session_token).unwrap(); + + let result = call_attach_to_remote_session( + server_url, None, // No auth token provided + false, false, + ) + .await; + + assert!(result.is_ok(), "Should successfully use saved token"); + + // Verify we did NOT call login endpoint (used saved token directly) + let endpoints = server_state.get_endpoints_called(); + assert!( + !endpoints.contains(&"/command/login".to_string()), + "Should NOT call login endpoint" + ); + assert!( + endpoints.contains(&"/session".to_string()), + "Should call session endpoint" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_token_flag_deletes_saved_token() { + let server_state = MockRemoteServerState::new(); + let auth_token = "new-auth-token"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + // Pre-save an old token + remote_session_tokens::save_session_token(&base_url, "old-token").unwrap(); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), // Providing new token + false, + false, + ) + .await; + + assert!(result.is_ok(), "Should succeed with new token"); + + // The old token should have been deleted before using new one + // (New token won't be saved because remember=false) + // Verify by checking that session endpoint was called (not using saved token) + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/command/login".to_string()), + "Should use new auth token, not saved token" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_successful_websocket_establishment() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-ws-token"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/test-session", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = + call_attach_to_remote_session(server_url, Some(auth_token.to_string()), false, false) + .await; + + assert!( + result.is_ok(), + "WebSocket connections should be established" + ); + + let connections = result.unwrap(); + assert!( + !connections.web_client_id.is_empty(), + "Should have web_client_id" + ); + + // Verify both WebSocket endpoints were called + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/ws/terminal".to_string()), + "Terminal WebSocket should be established" + ); + assert!( + endpoints.contains(&"/ws/control".to_string()), + "Control WebSocket should be established" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + async fn test_url_parsing_for_session_name() { + // Test various URL formats + let test_cases = vec![ + ("https://example.com/my-session", "my-session"), + ("https://example.com/", ""), + ("https://example.com/path/to/session", "path/to/session"), + ("http://localhost:8080/test", "test"), + ]; + + for (url, expected_name) in test_cases { + let result = extract_session_name(url); + assert!(result.is_ok(), "Failed to parse URL: {}", url); + assert_eq!( + result.unwrap(), + expected_name, + "Wrong session name for URL: {}", + url + ); + } + } + + #[tokio::test] + async fn test_server_url_extraction() { + // Test various URL formats + let test_cases = vec![ + ( + "https://example.com:8080/session?foo=bar", + "https://example.com:8080", + ), + ("http://localhost/test", "http://localhost"), + ( + "https://example.com/path/to/session#anchor", + "https://example.com", + ), + ]; + + for (url, expected_base) in test_cases { + let result = extract_server_url(url); + assert!(result.is_ok(), "Failed to extract server URL: {}", url); + assert_eq!( + result.unwrap(), + expected_base, + "Wrong base URL for: {}", + url + ); + } + } + + #[tokio::test] + async fn test_invalid_url_format() { + let result = call_attach_to_remote_session( + "not-a-valid-url".to_string(), + Some("token".to_string()), + false, + false, + ) + .await; + + assert!(result.is_err(), "Should fail with malformed URL"); + assert!(matches!( + result.unwrap_err(), + RemoteClientError::UrlParseError(_) + )); + } + + // -- TLS tests ------------------------------------------------------------ + // + // These tests exercise the rustls WebSocket TLS code paths added by the + // native-tls → rustls migration. They call establish_websocket_connections + // directly rather than going through attach_to_remote_session, because: + // + // 1. The HTTP auth step (isahc/curl) uses a separate TLS stack that was + // not changed by this migration — it is tested by the non-TLS tests above. + // 2. attach_to_remote_session opens the SQLite session-token database, + // which can cause I/O contention with web_client tests that use a + // different SQLite database in the same directory. + // + // Each test seeds a session directly in the mock server state and + // pre-populates the HTTP client cookie, then connects over wss://. + + /// Helper: create an HTTP client with a pre-seeded session cookie and + /// register the session in the mock server state. Returns (web_client_id, + /// http_client). + fn seed_mock_session( + server_state: &MockRemoteServerState, + ) -> ( + String, + crate::remote_attach::http_client::HttpClientWithCookies, + ) { + let session_token = uuid::Uuid::new_v4().to_string(); + let web_client_id = uuid::Uuid::new_v4().to_string(); + server_state + .session_tokens + .lock() + .unwrap() + .insert(session_token.clone(), web_client_id.clone()); + + // The HTTP client is only used for its cookie jar (WebSocket upgrade + // sends the session cookie). TLS for this client is irrelevant since + // it never makes HTTP requests in these tests. + let http_client = + crate::remote_attach::http_client::HttpClientWithCookies::new(None, true).unwrap(); + http_client.set_cookie("session_token".to_string(), session_token); + + (web_client_id, http_client) + } + + #[tokio::test] + #[serial] + async fn test_tls_insecure_mode() { + use crate::remote_attach::websockets; + + let certs = tls_mock_server::generate_test_certs(); + let server_state = MockRemoteServerState::new(); + + let (port, handle, server_task) = + tls_mock_server::start_tls_mock_server(server_state.clone(), &certs).await; + + let (web_client_id, http_client) = seed_mock_session(&server_state); + let server_base_url = format!("https://127.0.0.1:{}", port); + + let result = tokio::time::timeout( + Duration::from_secs(10), + websockets::establish_websocket_connections( + &web_client_id, + &http_client, + &server_base_url, + "test-session", + None, + true, // insecure — exercises NoVerifier + ), + ) + .await + .expect("Test timed out"); + + assert!( + result.is_ok(), + "TLS insecure mode should connect successfully: {:?}", + result.err() + ); + + let connections = result.unwrap(); + assert!(!connections.web_client_id.is_empty()); + + let endpoints = server_state.get_endpoints_called(); + assert!(endpoints.contains(&"/ws/terminal".to_string())); + assert!(endpoints.contains(&"/ws/control".to_string())); + + tls_mock_server::shutdown_server(handle, server_task).await; + } + + #[tokio::test] + #[serial] + async fn test_tls_ca_cert_mode() { + use crate::remote_attach::websockets; + + let certs = tls_mock_server::generate_test_certs(); + let server_state = MockRemoteServerState::new(); + + let (port, handle, server_task) = + tls_mock_server::start_tls_mock_server(server_state.clone(), &certs).await; + + let (web_client_id, http_client) = seed_mock_session(&server_state); + let server_base_url = format!("https://127.0.0.1:{}", port); + + let result = tokio::time::timeout( + Duration::from_secs(10), + websockets::establish_websocket_connections( + &web_client_id, + &http_client, + &server_base_url, + "test-session", + Some(certs.ca_cert_path.as_path()), + false, // not insecure — verify against CA cert + ), + ) + .await + .expect("Test timed out"); + + assert!( + result.is_ok(), + "WebSocket TLS with CA cert should connect successfully: {:?}", + result.err() + ); + + let connections = result.unwrap(); + assert!(!connections.web_client_id.is_empty()); + + let endpoints = server_state.get_endpoints_called(); + assert!(endpoints.contains(&"/ws/terminal".to_string())); + assert!(endpoints.contains(&"/ws/control".to_string())); + + tls_mock_server::shutdown_server(handle, server_task).await; + } + + #[tokio::test] + #[serial] + async fn test_tls_rejects_untrusted_cert() { + use crate::remote_attach::websockets; + + let certs = tls_mock_server::generate_test_certs(); + let server_state = MockRemoteServerState::new(); + + let (port, handle, server_task) = + tls_mock_server::start_tls_mock_server(server_state.clone(), &certs).await; + + let (web_client_id, http_client) = seed_mock_session(&server_state); + let server_base_url = format!("https://127.0.0.1:{}", port); + + let result = tokio::time::timeout( + Duration::from_secs(10), + websockets::establish_websocket_connections( + &web_client_id, + &http_client, + &server_base_url, + "test-session", + None, // no CA cert + false, // not insecure — should reject self-signed + ), + ) + .await + .expect("Test timed out"); + + assert!( + result.is_err(), + "TLS without CA cert should reject self-signed server" + ); + + tls_mock_server::shutdown_server(handle, server_task).await; + } +} + +// Tests that don't require the web_server_capability feature +#[cfg(not(feature = "web_server_capability"))] +mod tests { + use super::*; + + #[test] + fn test_url_parsing_without_server() { + // Basic URL parsing tests that don't require a server + let result = extract_session_name("https://example.com/my-session"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "my-session"); + + let result = extract_server_url("https://example.com:8080/session?foo=bar"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://example.com:8080"); + } +} diff --git a/sl0ppij/zellij-client/src/remote_attach/websockets.rs b/sl0ppij/zellij-client/src/remote_attach/websockets.rs new file mode 100644 index 0000000..b5ffdba --- /dev/null +++ b/sl0ppij/zellij-client/src/remote_attach/websockets.rs @@ -0,0 +1,278 @@ +use super::config::{WS_CONTROL_ENDPOINT, WS_TERMINAL_ENDPOINT}; +use super::http_client::HttpClientWithCookies; +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio_tungstenite::WebSocketStream; + +// -- MaybeTls stream enum ------------------------------------------------- + +/// A TCP stream that may or may not be wrapped in TLS. +pub enum MaybeTls { + Plain(TcpStream), + Tls(tokio_rustls::client::TlsStream<TcpStream>), +} + +impl AsyncRead for MaybeTls { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll<std::io::Result<()>> { + match self.get_mut() { + MaybeTls::Plain(s) => Pin::new(s).poll_read(cx, buf), + MaybeTls::Tls(s) => Pin::new(s).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for MaybeTls { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll<std::io::Result<usize>> { + match self.get_mut() { + MaybeTls::Plain(s) => Pin::new(s).poll_write(cx, buf), + MaybeTls::Tls(s) => Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> { + match self.get_mut() { + MaybeTls::Plain(s) => Pin::new(s).poll_flush(cx), + MaybeTls::Tls(s) => Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> { + match self.get_mut() { + MaybeTls::Plain(s) => Pin::new(s).poll_shutdown(cx), + MaybeTls::Tls(s) => Pin::new(s).poll_shutdown(cx), + } + } +} + +// -- NoVerifier (for --insecure mode) -------------------------------------- + +#[derive(Debug)] +struct NoVerifier; + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer<'_>, + _intermediates: &[rustls_pki_types::CertificateDer<'_>], + _server_name: &rustls_pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls_pki_types::UnixTime, + ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +// -- TLS config builder ---------------------------------------------------- + +fn build_tls_config( + ca_cert: Option<&Path>, + insecure: bool, +) -> Result<Arc<rustls::ClientConfig>, Box<dyn std::error::Error>> { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + + if insecure { + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()? + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + .with_no_client_auth(); + Ok(Arc::new(config)) + } else if let Some(ca_path) = ca_cert { + let ca_pem = std::fs::read(ca_path)?; + let mut cursor = std::io::Cursor::new(ca_pem); + let certs: Vec<rustls_pki_types::CertificateDer<'static>> = + rustls_pemfile::certs(&mut cursor) + .filter_map(|r| r.ok()) + .collect(); + let mut root_store = rustls::RootCertStore::empty(); + for cert in certs { + root_store.add(cert)?; + } + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()? + .with_root_certificates(root_store) + .with_no_client_auth(); + Ok(Arc::new(config)) + } else { + let native_certs = rustls_native_certs::load_native_certs(); + for err in &native_certs.errors { + log::warn!("Error loading native certificate: {}", err); + } + let mut root_store = rustls::RootCertStore::empty(); + root_store.add_parsable_certificates(native_certs.certs); + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()? + .with_root_certificates(root_store) + .with_no_client_auth(); + Ok(Arc::new(config)) + } +} + +// -- WebSocket connection helpers ------------------------------------------ + +async fn connect_ws( + request: tokio_tungstenite::tungstenite::http::Request<()>, + host: &str, + port: u16, + tls_config: Option<Arc<rustls::ClientConfig>>, +) -> Result<WebSocketStream<MaybeTls>, Box<dyn std::error::Error>> { + let tcp_stream = TcpStream::connect((host, port)).await?; + + let stream = if let Some(config) = tls_config { + let connector = tokio_rustls::TlsConnector::from(config); + let server_name = rustls_pki_types::ServerName::try_from(host.to_string())?; + let tls_stream = connector.connect(server_name, tcp_stream).await?; + MaybeTls::Tls(tls_stream) + } else { + MaybeTls::Plain(tcp_stream) + }; + + let (ws_stream, _response) = + tokio_tungstenite::client_async_with_config(request, stream, None).await?; + Ok(ws_stream) +} + +// -- Public API ------------------------------------------------------------ + +pub struct WebSocketConnections { + pub terminal_ws: WebSocketStream<MaybeTls>, + pub control_ws: WebSocketStream<MaybeTls>, + pub web_client_id: String, +} + +impl std::fmt::Debug for WebSocketConnections { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebSocketConnections") + .field("web_client_id", &self.web_client_id) + .finish() + } +} + +pub async fn establish_websocket_connections( + web_client_id: &str, + http_client: &HttpClientWithCookies, + server_base_url: &str, + session_name: &str, + ca_cert: Option<&Path>, + insecure: bool, +) -> Result<WebSocketConnections, Box<dyn std::error::Error>> { + let parsed_url = url::Url::parse(server_base_url)?; + let host = parsed_url + .host_str() + .ok_or("no host in server URL")? + .to_string(); + let port = parsed_url + .port_or_known_default() + .ok_or("no port in server URL")?; + let is_tls = parsed_url.scheme() == "https"; + + let ws_protocol = if is_tls { "wss" } else { "ws" }; + let base_host = format!("{}:{}", host, port); + + let terminal_url = if session_name.is_empty() { + format!( + "{}://{}{WS_TERMINAL_ENDPOINT}?web_client_id={}", + ws_protocol, + base_host, + urlencoding::encode(web_client_id) + ) + } else { + format!( + "{}://{}{WS_TERMINAL_ENDPOINT}/{}?web_client_id={}", + ws_protocol, + base_host, + urlencoding::encode(session_name), + urlencoding::encode(web_client_id) + ) + }; + + let control_url = format!("{}://{}{WS_CONTROL_ENDPOINT}", ws_protocol, base_host); + + log::info!("Connecting to terminal WebSocket: {}", terminal_url); + log::info!("Connecting to control WebSocket: {}", control_url); + + // Build WebSocket requests with cookies + let mut terminal_request = tokio_tungstenite::tungstenite::http::Request::builder() + .uri(&terminal_url) + .header("Host", &base_host) + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header( + "Sec-WebSocket-Key", + tokio_tungstenite::tungstenite::handshake::client::generate_key(), + ) + .header("Sec-WebSocket-Version", "13"); + + let mut control_request = tokio_tungstenite::tungstenite::http::Request::builder() + .uri(&control_url) + .header("Host", &base_host) + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header( + "Sec-WebSocket-Key", + tokio_tungstenite::tungstenite::handshake::client::generate_key(), + ) + .header("Sec-WebSocket-Version", "13"); + + // Add cookies if available + if let Some(cookie_header) = http_client.get_cookie_header() { + terminal_request = terminal_request.header("Cookie", &cookie_header); + control_request = control_request.header("Cookie", &cookie_header); + } + + let terminal_request = terminal_request.body(())?; + let control_request = control_request.body(())?; + + // Build TLS config (only for wss://) + let tls_config = if is_tls { + Some(build_tls_config(ca_cert, insecure)?) + } else { + None + }; + + // Connect to both WebSockets + let terminal_ws = connect_ws(terminal_request, &host, port, tls_config.clone()).await?; + let control_ws = connect_ws(control_request, &host, port, tls_config).await?; + + Ok(WebSocketConnections { + terminal_ws, + control_ws, + web_client_id: web_client_id.to_owned(), + }) +} |
