diff --git a/crates/desktop/src/daemon.rs b/crates/desktop/src/daemon.rs index af79024..55ad46a 100644 --- a/crates/desktop/src/daemon.rs +++ b/crates/desktop/src/daemon.rs @@ -62,9 +62,17 @@ pub struct ProxyInstance { impl ProxyInstance { pub fn new( label: impl AsRef, scope_host: impl AsRef, listener: tokio::net::TcpListener, - remote: impl AsRef, + remote: impl AsRef, accept_invalid_certs: bool, ) -> Self { - let tunnel = Tunnel::new(remote.as_ref(), listener); + let tunnel = if accept_invalid_certs { + warn!( + "CREATE insecure tunnel: certificate verification is disabled for {}", + remote.as_ref() + ); + Tunnel::with_insecure_tls(remote.as_ref(), listener) + } else { + Tunnel::new(remote.as_ref(), listener) + }; Self { data: InstanceData { @@ -304,6 +312,8 @@ pub async fn launch_instance( ) -> Result { use wsrx::utils::create_tcp_listener; + let accept_invalid_certs = state.settings.read().await.insecure_tls; + let listener = create_tcp_listener(&instance_data.local).await?; let local = listener @@ -342,6 +352,7 @@ pub async fn launch_instance( scope.clone(), listener, instance_data.remote.clone(), + accept_invalid_certs, ); let instance_resp: InstanceData = (&instance).into(); @@ -351,7 +362,18 @@ pub async fn launch_instance( let state_clone = state.clone(); let instance = instance_resp.clone(); tokio().spawn(async move { - let client = reqwest::Client::new(); + let mut client = + reqwest::Client::builder().user_agent(format!("wsrx/{}", env!("CARGO_PKG_VERSION"))); + if accept_invalid_certs { + client = client.danger_accept_invalid_certs(true); + } + let client = match client.build() { + Ok(client) => client, + Err(err) => { + error!("Failed to build latency probe client: {err}"); + return; + } + }; match workers::update_instance_latency(&instance, &client).await { Ok(elapsed) => workers::update_instance_state(&state_clone, &instance, elapsed).await, Err(_) => workers::update_instance_state(&state_clone, &instance, -1).await, diff --git a/crates/desktop/src/daemon/workers.rs b/crates/desktop/src/daemon/workers.rs index 8c4e238..6d3698c 100644 --- a/crates/desktop/src/daemon/workers.rs +++ b/crates/desktop/src/daemon/workers.rs @@ -13,7 +13,8 @@ use crate::{ /// Periodically pings every instance and updates its latency in the UI. pub async fn latency_loop(state: ServerState) { - let client = reqwest::Client::new(); + let mut client = build_latency_client(&state).await; + let mut client_insecure = state.settings.read().await.insecure_tls; loop { let instances = state.instances.read().await; let instances_pure = instances @@ -44,11 +45,34 @@ pub async fn latency_loop(state: ServerState) { state.events.send(UiEvent::Refresh).await.ok(); } + // Rebuild the probing client when the insecure-TLS setting changes, + // so that latency probes follow the same certificate policy as the + // actual tunnels. + let insecure_tls = state.settings.read().await.insecure_tls; + if insecure_tls != client_insecure { + client = build_latency_client(&state).await; + client_insecure = insecure_tls; + } + // Sleep for 5 seconds tokio::time::sleep(Duration::from_secs(5)).await; } } +/// Builds the HTTP client used to probe instance latency. When the user +/// enabled insecure TLS, invalid server certificates are accepted as well. +async fn build_latency_client(state: &ServerState) -> reqwest::Client { + let mut builder = + reqwest::Client::builder().user_agent(format!("wsrx/{}", env!("CARGO_PKG_VERSION"))); + if state.settings.read().await.insecure_tls { + builder = builder.danger_accept_invalid_certs(true); + } + builder.build().unwrap_or_else(|err| { + error!("Failed to build latency probe client: {err}"); + reqwest::Client::new() + }) +} + #[derive(Debug, Error)] pub enum LatencyError { #[error("Request error: {0}")] diff --git a/crates/desktop/src/i18n.rs b/crates/desktop/src/i18n.rs index 9d40105..bd58350 100644 --- a/crates/desktop/src/i18n.rs +++ b/crates/desktop/src/i18n.rs @@ -84,6 +84,16 @@ static TABLE: Lazy> = Lazy::new(|| { ("Quit", "退出", "退出"), ("Enabled", "已启用", "已啟用"), ("Disabled", "已禁用", "已停用"), + ( + "Allow insecure TLS connections", + "允许不安全的 TLS 连接", + "允許不安全的 TLS 連線", + ), + ( + "WARNING: when enabled, wsrx will skip certificate verification and unconditionally trust any certificate for wss:// connections, including self-signed or forged ones. This makes you vulnerable to man-in-the-middle attacks. Only enable it when you know what you are doing.", + "警告:启用后,wsrx 将跳过证书校验,无条件信任 wss:// 连接的任何证书(包括自签名或伪造的证书),使你面临中间人攻击的风险。仅在明确知晓后果时才应启用。", + "警告:啟用後,wsrx 將跳過憑證驗證,無條件信任 wss:// 連線的任何憑證(包括自簽名或偽造的憑證),使你面臨中間人攻擊的風險。僅在明確知曉後果時才應啟用。", + ), ("Language / Locale", "语言 / 区域", "語言 / 地區"), ("English", "English", "English"), ("简体中文", "简体中文", "简体中文"), diff --git a/crates/desktop/src/models.rs b/crates/desktop/src/models.rs index 38544f9..b5c54c0 100644 --- a/crates/desktop/src/models.rs +++ b/crates/desktop/src/models.rs @@ -40,6 +40,10 @@ pub struct WsrxDesktopConfig { pub running_in_tray: bool, #[serde(default = "default_language")] pub language: String, + /// When true, connect to `wss://` remotes without verifying the server + /// certificate. Disabled by default. + #[serde(default)] + pub insecure_tls: bool, } impl Default for WsrxDesktopConfig { @@ -48,6 +52,7 @@ impl Default for WsrxDesktopConfig { theme: default_theme(), running_in_tray: default_running_in_tray(), language: default_language(), + insecure_tls: false, } } } diff --git a/crates/desktop/src/ui/connections.rs b/crates/desktop/src/ui/connections.rs index 28cdc36..414b367 100644 --- a/crates/desktop/src/ui/connections.rs +++ b/crates/desktop/src/ui/connections.rs @@ -20,6 +20,7 @@ pub(crate) fn render_connections( let theme = cx.theme(); let weak = cx.entity().downgrade(); let state = root.state().clone(); + let insecure_tls = root.settings().insecure_tls; let scope = root.scope_for_page(); let instances = root.scoped_instances(); @@ -218,6 +219,7 @@ pub(crate) fn render_connections( muted_foreground: theme.muted_foreground, border: theme.border, }, + insecure_tls, ) })) .child(div().h_6()) @@ -246,6 +248,7 @@ struct InstanceRowColors { fn render_instance_row( state: &crate::daemon::ServerState, instance: &InstanceData, colors: InstanceRowColors, + insecure_tls: bool, ) -> impl IntoElement { let state = state.clone(); let local = instance.local.clone(); @@ -253,6 +256,9 @@ fn render_instance_row( let remote = instance.remote.clone(); let label = instance.label.clone(); let latency = instance.latency; + // Connections over wss:// are made without certificate verification when + // insecure TLS is enabled; mark them with an insecure icon. + let is_insecure = insecure_tls && remote.starts_with("wss://"); let latency_text = if latency >= 0 { format!("{latency} ms") @@ -294,10 +300,17 @@ fn render_instance_row( .items_center() .gap_4() .child( - div() + h_flex() .flex_1() - .text_color(colors.muted_foreground) - .child(remote), + .min_w_0() + .items_center() + .gap_2() + .when(is_insecure, |this| { + this.child( + Icon::new(IconName::ShieldError).text_color(colors.danger), + ) + }) + .child(div().text_color(colors.muted_foreground).child(remote)), ) .child( h_flex() diff --git a/crates/desktop/src/ui/settings.rs b/crates/desktop/src/ui/settings.rs index 9a6c2a8..077ea5e 100644 --- a/crates/desktop/src/ui/settings.rs +++ b/crates/desktop/src/ui/settings.rs @@ -20,6 +20,7 @@ pub(crate) fn render_settings( let version = root.version().to_string(); let language = root.settings().language.clone(); let running_in_tray = root.settings().running_in_tray; + let insecure_tls = root.settings().insecure_tls; let cursor = if root.cursor_visible() { "_" } else { " " }; let info = root.info().to_string(); @@ -170,6 +171,60 @@ pub(crate) fn render_settings( ), ) .child(div().h_px().bg(border)) + .child( + // Insecure TLS: unconditionally trust server certificates + v_flex() + .gap_1() + .child(settings_row( + cx, + i18n::t("Allow insecure TLS connections"), + Button::new("insecure-tls-toggle") + .flat() + .icon(Icon::new(if insecure_tls { + IconName::ToggleRight + } else { + IconName::ToggleLeft + })) + .label(if insecure_tls { + i18n::t("Enabled") + } else { + i18n::t("Disabled") + }) + .on_click({ + let weak = weak.clone(); + let state = state.clone(); + move |_, _, cx| { + let enabled = { + let mut settings = state.settings.blocking_write(); + settings.insecure_tls = !settings.insecure_tls; + settings.insecure_tls + }; + if enabled { + tracing::warn!( + "Insecure TLS is enabled by the user, \ + certificate verification is now disabled." + ); + } + daemon::persist_settings_sync(&state); + let _ = weak.update(cx, |root, cx| { + root.settings.insecure_tls = enabled; + cx.notify(); + }); + } + }), + )) + .child( + div() + .pl_3() + .pr_3() + .text_color(cx.theme().danger) + .opacity(0.85) + .child(i18n::t( + "WARNING: when enabled, wsrx will skip certificate verification and unconditionally trust any certificate for wss:// connections, including self-signed or forged ones. This makes you vulnerable to man-in-the-middle attacks. Only enable it when you know what you are doing.", + )), + ), + ) + .child(div().h_px().bg(border)) .child( // Export network logs settings_row( diff --git a/crates/wsrx/src/tunnel.rs b/crates/wsrx/src/tunnel.rs index 5c84a8c..9e70fda 100644 --- a/crates/wsrx/src/tunnel.rs +++ b/crates/wsrx/src/tunnel.rs @@ -1,7 +1,12 @@ use std::sync::Arc; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::CryptoProvider; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{DigitallySignedStruct, Error, SignatureScheme}; use serde::{Deserialize, Serialize}; use tokio::{net::TcpListener, task::JoinHandle}; +use tokio_tungstenite::Connector; use tokio_util::sync::CancellationToken; use tracing::{error, info}; @@ -42,6 +47,21 @@ impl Serialize for Tunnel { impl Tunnel { /// Creates a new `Tunnel` instance. pub fn new(remote: impl AsRef, listener: TcpListener) -> Self { + Self::build(remote, listener, false) + } + + /// Creates a new `Tunnel` instance that connects to `wss://` remotes + /// without verifying the server certificate at all. Every certificate, + /// including self-signed, expired or mismatched ones, is trusted + /// unconditionally. + /// + /// This is insecure and must only be used when the user explicitly opts + /// in from the application settings. + pub fn with_insecure_tls(remote: impl AsRef, listener: TcpListener) -> Self { + Self::build(remote, listener, true) + } + + fn build(remote: impl AsRef, listener: TcpListener, accept_invalid_certs: bool) -> Self { let local = listener .local_addr() .expect("failed to bind port") @@ -56,6 +76,12 @@ impl Tunnel { remote: remote.as_ref().to_string(), }; + let tls_connector = if accept_invalid_certs { + Some(Connector::Rustls(Arc::new(build_insecure_client_config()))) + } else { + None + }; + let loop_config = Arc::new(config.clone()); let loop_token = token.clone(); let handle = tokio::spawn(async move { @@ -80,11 +106,19 @@ impl Tunnel { let proxy_config = loop_config.clone(); let proxy_token = loop_token.clone(); + let connector = tls_connector.clone(); tokio::spawn(async move { - use tokio_tungstenite::connect_async; - - let ws = match connect_async(proxy_config.remote.as_str()).await { + use tokio_tungstenite::connect_async_tls_with_config; + + let ws = match connect_async_tls_with_config( + proxy_config.remote.as_str(), + None, + false, + connector, + ) + .await + { Ok((ws, _)) => ws, Err(e) => { error!("Failed to connect to {}: {}", proxy_config.remote, e); @@ -138,3 +172,55 @@ impl std::ops::DerefMut for Tunnel { &mut self.config } } + +/// A `ServerCertVerifier` that accepts every server certificate without any +/// verification. Used only when the user explicitly opts in to insecure TLS +/// connections. +#[derive(Debug)] +struct AcceptAnyServerCert { + schemes: Vec, +} + +impl ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, _end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, _ocsp_response: &[u8], _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.schemes.clone() + } +} + +/// Builds a rustls client config that unconditionally trusts any server +/// certificate. Prefers the process-wide crypto provider when one is +/// installed, and falls back to the ring provider otherwise. +fn build_insecure_client_config() -> rustls::ClientConfig { + let provider = CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); + let schemes = provider + .signature_verification_algorithms + .supported_schemes(); + + rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(rustls::ALL_VERSIONS) + .expect("supported protocol versions") + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert { schemes })) + .with_no_client_auth() +}