aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij/zellij-client/src/remote_attach
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 19:34:15 +0200
committeruser <user@clank>2026-08-12 19:34:15 +0200
commitd1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch)
tree9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-client/src/remote_attach
parentchore: initialize sl0ppi monorepo (diff)
downloadsl0ppi-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.rs149
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/config.rs14
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/http_client.rs122
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/mod.rs267
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/unit/mod.rs1
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/unit/remote_attach_tests.rs960
-rw-r--r--sl0ppij/zellij-client/src/remote_attach/websockets.rs278
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 &current_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(),
+ })
+}