diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-31 20:19:50 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-31 20:19:50 +0200 |
| commit | 7c68623580342940d7cd53bc8bce1926841feb47 (patch) | |
| tree | 9dc84cebbe7a64f4486cc2dc492080903f7a7bcf | |
| parent | Preserve scrollback for partial scroll regions starting at top (#4941) (diff) | |
| download | sl0ppij-7c68623580342940d7cd53bc8bce1926841feb47.tar.gz sl0ppij-7c68623580342940d7cd53bc8bce1926841feb47.tar.xz sl0ppij-7c68623580342940d7cd53bc8bce1926841feb47.zip | |
Move from native-tls to rustls in the remote client (mirrors the web server) (#4972)
* native-tls -> rustls
* add/update tests
* rustfmt
* add pr
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | Cargo.lock | 180 | ||||
| -rw-r--r-- | zellij-client/Cargo.toml | 10 | ||||
| -rw-r--r-- | zellij-client/src/remote_attach/mod.rs | 4 | ||||
| -rw-r--r-- | zellij-client/src/remote_attach/unit/remote_attach_tests.rs | 284 | ||||
| -rw-r--r-- | zellij-client/src/remote_attach/websockets.rs | 221 | ||||
| -rw-r--r-- | zellij-client/src/unit/terminal_loop_tests.rs | 135 | ||||
| -rw-r--r-- | zellij-utils/src/remote_session_tokens.rs | 1 | ||||
| -rw-r--r-- | zellij-utils/src/web_authentication_tokens.rs | 1 |
9 files changed, 684 insertions, 153 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b152f4..6ba6b22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * feat: add --layout-string to allow for in-line layouts, add --tab-id to all new-pane CLI commands (https://github.com/zellij-org/zellij/pull/4956) * fix: proper error on socket paths that are too long (https://github.com/zellij-org/zellij/pull/4957) * fix: scrollback with partial scroll region (eg. Codex bug) (https://github.com/zellij-org/zellij/pull/4941) +* fix: remote attach issues on macOS (https://github.com/zellij-org/zellij/pull/4972) ## [0.44.0] - 2026-03-23 * fix: meta key handling in web client (https://github.com/zellij-org/zellij/pull/4376) @@ -331,9 +331,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -623,9 +623,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -720,7 +720,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "crossterm_winapi", "mio 1.0.3", "parking_lot 0.12.1", @@ -767,7 +767,7 @@ checksum = "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22" dependencies = [ "curl-sys", "libc", - "openssl-probe", + "openssl-probe 0.1.5", "openssl-sys", "schannel", "socket2 0.4.9", @@ -1085,21 +1085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1974,7 +1959,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.5.13", ] @@ -2224,23 +2209,6 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] name = "nix" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2269,7 +2237,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2345,36 +2313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.5.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" +name = "openssl-probe" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.96", -] +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" @@ -2471,6 +2419,16 @@ dependencies = [ ] [[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2744,6 +2702,19 @@ dependencies = [ ] [[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] name = "recvmsg" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2773,7 +2744,7 @@ version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", ] [[package]] @@ -2824,7 +2795,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78046161564f5e7cd9008aff3b2990b3850dc8e0349119b98e8f251e099f24d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2867,7 +2838,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2880,7 +2851,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2904,6 +2875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -2912,6 +2884,18 @@ dependencies = [ ] [[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] name = "rustls-pemfile" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2994,11 +2978,11 @@ checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" [[package]] name = "security-framework" -version = "2.10.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -3007,9 +2991,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3529,7 +3513,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b858526d22750088a9b3cf2e3c2aacebd5377f13adeec02860c30d09113010a6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "cap-fs-ext", "cap-std", "fd-lock", @@ -3723,16 +3707,6 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] name = "tokio-rustls" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3761,9 +3735,7 @@ checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", - "native-tls", "tokio", - "tokio-native-tls", "tungstenite 0.20.1", ] @@ -3863,7 +3835,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "bytes", "http 1.3.1", "pin-project-lite", @@ -3937,7 +3909,6 @@ dependencies = [ "http 0.2.9", "httparse", "log", - "native-tls", "rand 0.8.5", "sha1", "thiserror 1.0.61", @@ -4187,7 +4158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82735a180c58e86727965ca39c0390d21c86a354f03cf7a3fc73f1de4c01ae1c" dependencies = [ "anyhow", - "bitflags 2.5.0", + "bitflags 2.11.0", "cap-fs-ext", "cap-rand", "cap-std", @@ -4326,7 +4297,7 @@ version = "0.228.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "indexmap 2.7.1", ] @@ -4336,7 +4307,7 @@ version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "indexmap 2.7.1", ] @@ -4405,7 +4376,7 @@ checksum = "b61d3bcb6a981233e8b774738f4495ad2c724a0d73ac626f373944e9ba1e3101" dependencies = [ "anyhow", "async-trait", - "bitflags 2.5.0", + "bitflags 2.11.0", "thiserror 2.0.12", "tracing", "wiggle-macro", @@ -4700,7 +4671,7 @@ version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9643b83820c0cd246ecabe5fa454dd04ba4fa67996369466d0747472d337346" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", "windows-sys 0.52.0", ] @@ -4710,7 +4681,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.0", ] [[package]] @@ -4784,6 +4755,15 @@ dependencies = [ ] [[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4853,18 +4833,24 @@ dependencies = [ "isahc", "lazy_static", "log", - "native-tls", "nix", "notify", "num_cpus", + "rcgen", "regex", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_yaml", "serial_test", "signal-hook", + "tempfile", "time", "tokio", + "tokio-rustls", "tokio-tungstenite 0.20.1", "tokio-util", "tower-http", @@ -4951,7 +4937,7 @@ version = "0.44.0" dependencies = [ "anyhow", "backtrace", - "bitflags 2.5.0", + "bitflags 2.11.0", "clap", "clap_complete", "colored", diff --git a/zellij-client/Cargo.toml b/zellij-client/Cargo.toml index 2cb1492a..a83b4dd0 100644 --- a/zellij-client/Cargo.toml +++ b/zellij-client/Cargo.toml @@ -39,8 +39,12 @@ zellij-utils = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } isahc = { workspace = true } -tokio-tungstenite = { version = "0.20", features = ["native-tls"] } #TODO: see about this native-tls... -native-tls = "0.2" +tokio-tungstenite = "0.20" +rustls = { version = "0.23", default-features = false, features = ["std"] } +tokio-rustls = "0.26" +rustls-pemfile = "2" +rustls-native-certs = "0.8" +rustls-pki-types = "1" futures-util = "0.3" urlencoding = "2.1" @@ -61,8 +65,10 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] insta = "1.6.0" +rcgen = "0.13" serde_json = "1.0" serial_test = "3.0" +tempfile = "3" [features] unstable = [] diff --git a/zellij-client/src/remote_attach/mod.rs b/zellij-client/src/remote_attach/mod.rs index 45a8a032..99f8b92f 100644 --- a/zellij-client/src/remote_attach/mod.rs +++ b/zellij-client/src/remote_attach/mod.rs @@ -1,7 +1,7 @@ mod auth; mod config; -mod http_client; -mod websockets; +pub mod http_client; +pub mod websockets; #[cfg(test)] mod unit; diff --git a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs index 68dd0907..8a57b589 100644 --- a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs +++ b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs @@ -59,11 +59,11 @@ mod mock_server { } #[derive(Deserialize)] - struct LoginRequest { - auth_token: String, + pub struct LoginRequest { + pub auth_token: String, } - async fn handle_login( + pub async fn handle_login( State(state): State<MockRemoteServerState>, jar: CookieJar, Json(payload): Json<LoginRequest>, @@ -101,7 +101,7 @@ mod mock_server { )) } - async fn handle_session( + pub async fn handle_session( State(state): State<MockRemoteServerState>, jar: CookieJar, ) -> Result<Json<serde_json::Value>, StatusCode> { @@ -124,7 +124,7 @@ mod mock_server { }))) } - async fn handle_ws_terminal( + pub async fn handle_ws_terminal( ws: axum::extract::ws::WebSocketUpgrade, State(state): State<MockRemoteServerState>, jar: CookieJar, @@ -157,7 +157,7 @@ mod mock_server { })) } - async fn handle_ws_control( + pub async fn handle_ws_control( ws: axum::extract::ws::WebSocketUpgrade, State(state): State<MockRemoteServerState>, jar: CookieJar, @@ -215,6 +215,112 @@ mod mock_server { } } +#[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::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 server_cert = server_params.signed_by(&server_key, &ca, &ca_key).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, 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"); + 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) + .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, 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); @@ -658,6 +764,172 @@ mod tests { 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 diff --git a/zellij-client/src/remote_attach/websockets.rs b/zellij-client/src/remote_attach/websockets.rs index 07776466..b5ffdba1 100644 --- a/zellij-client/src/remote_attach/websockets.rs +++ b/zellij-client/src/remote_attach/websockets.rs @@ -1,14 +1,178 @@ 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::{ - connect_async_tls_with_config, Connector, MaybeTlsStream, WebSocketStream, -}; +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<MaybeTlsStream<TcpStream>>, - pub control_ws: WebSocketStream<MaybeTlsStream<TcpStream>>, + pub terminal_ws: WebSocketStream<MaybeTls>, + pub control_ws: WebSocketStream<MaybeTls>, pub web_client_id: String, } @@ -28,14 +192,18 @@ pub async fn establish_websocket_connections( ca_cert: Option<&Path>, insecure: bool, ) -> Result<WebSocketConnections, Box<dyn std::error::Error>> { - let ws_protocol = if server_base_url.starts_with("https") { - "wss" - } else { - "ws" - }; - let base_host = server_base_url - .replace("https://", "") - .replace("http://", ""); + 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!( @@ -59,7 +227,7 @@ pub async fn establish_websocket_connections( log::info!("Connecting to terminal WebSocket: {}", terminal_url); log::info!("Connecting to control WebSocket: {}", control_url); - // Create WebSocket request with cookies + // Build WebSocket requests with cookies let mut terminal_request = tokio_tungstenite::tungstenite::http::Request::builder() .uri(&terminal_url) .header("Host", &base_host) @@ -91,29 +259,16 @@ pub async fn establish_websocket_connections( let terminal_request = terminal_request.body(())?; let control_request = control_request.body(())?; - // Build TLS connector based on ca_cert/insecure settings - let connector = if insecure { - let tls_connector = native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build()?; - Some(Connector::NativeTls(tls_connector)) - } else if let Some(ca_path) = ca_cert { - let ca_pem = std::fs::read(ca_path)?; - let ca_cert = native_tls::Certificate::from_pem(&ca_pem)?; - let tls_connector = native_tls::TlsConnector::builder() - .add_root_certificate(ca_cert) - .build()?; - Some(Connector::NativeTls(tls_connector)) + // 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 concurrently - let (terminal_ws, _) = - connect_async_tls_with_config(terminal_request, None, false, connector.clone()).await?; - let (control_ws, _) = - connect_async_tls_with_config(control_request, None, false, connector).await?; + // 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, diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs index d48da54e..87890193 100644 --- a/zellij-client/src/unit/terminal_loop_tests.rs +++ b/zellij-client/src/unit/terminal_loop_tests.rs @@ -1,4 +1,5 @@ use crate::os_input_output::{AsyncSignals, AsyncStdin, ClientOsApi, SignalEvent}; +use crate::remote_attach::websockets::MaybeTls; use crate::remote_attach::WebSocketConnections; use crate::run_remote_client_terminal_loop; use crate::web_client::control_message::{ @@ -11,8 +12,8 @@ use serial_test::serial; use std::io::{self, Write}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tokio::net::TcpStream; use tokio::sync::mpsc; -use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use zellij_utils::data::Palette; use zellij_utils::errors::ErrorContext; @@ -325,8 +326,26 @@ async fn test_stdin_forwarded_to_terminal_websocket() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, @@ -379,8 +398,26 @@ async fn test_terminal_output_written_to_stdout() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, @@ -436,8 +473,26 @@ async fn test_resize_signal_sends_control_message() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, @@ -509,8 +564,26 @@ async fn test_quit_signal_exits_loop() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, @@ -546,8 +619,26 @@ async fn test_websocket_close_exits_loop() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, @@ -586,8 +677,26 @@ async fn test_control_message_handling() { let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); let control_url = format!("ws://127.0.0.1:{}/ws/control", port); - let (terminal_ws, _) = connect_async(&terminal_url).await.unwrap(); - let (control_ws, _) = connect_async(&control_url).await.unwrap(); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); let connections = WebSocketConnections { terminal_ws, diff --git a/zellij-utils/src/remote_session_tokens.rs b/zellij-utils/src/remote_session_tokens.rs index ef891c53..b0a323ee 100644 --- a/zellij-utils/src/remote_session_tokens.rs +++ b/zellij-utils/src/remote_session_tokens.rs @@ -44,6 +44,7 @@ fn get_db_path() -> Result<PathBuf> { } fn init_db(conn: &Connection) -> Result<()> { + conn.execute_batch("PRAGMA busy_timeout = 5000")?; conn.execute( "CREATE TABLE IF NOT EXISTS remote_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/zellij-utils/src/web_authentication_tokens.rs b/zellij-utils/src/web_authentication_tokens.rs index 0102be19..b579890f 100644 --- a/zellij-utils/src/web_authentication_tokens.rs +++ b/zellij-utils/src/web_authentication_tokens.rs @@ -89,6 +89,7 @@ fn open_db() -> Result<Connection> { } fn init_db(conn: &Connection) -> Result<()> { + conn.execute_batch("PRAGMA busy_timeout = 5000")?; conn.execute_batch("PRAGMA foreign_keys = ON")?; conn.execute( |
