From 47b2c05e1d3fe350e650cd652fef804614912c63 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 7 Aug 2026 00:18:16 +0800 Subject: [PATCH] =?UTF-8?q?ws:=20decouple=20ICE=20policy=20from=20force=5F?= =?UTF-8?q?relay=20=E2=80=94=20full-ICE=20WebRTC=20over=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebSocket support folds into force_relay because a ws tunnel kills classic TCP/UDP punching — but that conflated transport necessity with relay policy, and the WebRTC decisions keyed off the merged flag: a ws client built no offerer at all without TURN, and only a Relay-only-ICE one with it. ws deployments could never reach a direct WebRTC connection, which is exactly the path they are supposed to live on. Split the flag. LoginConfigHandler now tracks policy_relay (the force-always-relay option, an explicit relay request — /r ids and retry-via-relay included — and proxy) separately; force_relay stays policy_relay || use_ws() and keeps governing the classic paths, so non-ws behavior is unchanged everywhere: - the offerer's existence and ICE policy follow policy_relay: under pure ws the offer gathers every candidate type and may go direct; under relay-by-policy it stays Relay-only ICE, TURN-gated, exactly as before; - the RelayResponse race applies the prefer-P2P window under ws (a direct ICE path is worth delaying an already-ready relay for) while policy relay keeps first-success semantics; - the request carries webrtc_all_ice (hbb_common 64b54ab) so the controlled side knows the offer is full-ICE: it answers with full ICE and no TURN requirement, while offers without the bit keep today's relay-only answer path on every version-skew combination. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ --- libs/hbb_common | 2 +- src/client.rs | 47 +++++++++++++++++++++++++++---------- src/rendezvous_mediator.rs | 14 +++++++---- src/ui_session_interface.rs | 6 ++++- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index 7c4456be9..a992c646b 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 7c4456be9bdb5c53df01ebeac5a36d67a6392a38 +Subproject commit a992c646bf0e34245f80fb230e2b93b1cdd1da02 diff --git a/src/client.rs b/src/client.rs index 5f4c76719..983c8bece 100644 --- a/src/client.rs +++ b/src/client.rs @@ -499,7 +499,10 @@ impl Client { // When this request carries an offer, `_start_inner` keeps its rendezvous socket solely // for trickle signaling; a separate offer-less request owns any TCP punch attempt. let webrtc_offerer = if Self::should_create_webrtc_offerer(&interface) { - match WebRTCStream::new("", interface.is_force_relay(), CONNECT_TIMEOUT).await { + // ICE policy follows relay-by-POLICY, not force_relay: under WebSocket the + // latter is set for the classic paths, but ICE opens its own sockets and may + // still go direct - that is the only P2P path ws deployments have. + match WebRTCStream::new("", interface.is_policy_relay(), CONNECT_TIMEOUT).await { Ok(stream) => Some(stream), Err(err) => { log::warn!("webrtc offerer setup failed: {}", err); @@ -604,10 +607,13 @@ impl Client { /// ws deployments where classic punching is forced to relay. Independent of the udp-punch /// option too — the offer rides any request, and a server or peer without WebRTC support /// drops the field, so the race simply proceeds without it. - /// Under force_relay the pc uses Relay-only ICE, which gathers nothing and can never connect - /// unless a TURN server is configured, so skip building a guaranteed-dead pc + STUN/TURN - /// gathering + answerer signaling in that case too. - /// When force_relay *and* TURN are configured, WebRTC via TURN is a valid "relayed" path: + /// Under relay-by-POLICY (force-always-relay option or an explicit relay request) the pc + /// uses Relay-only ICE, which gathers nothing and can never connect unless a TURN server + /// is configured, so skip building a guaranteed-dead pc + STUN/TURN gathering + answerer + /// signaling in that case. WebSocket-forced relay is deliberately NOT policy: ws only + /// tunnels the signaling/relay legs, so the offer keeps full ICE and may land a direct + /// connection - the flagship path for ws deployments (`webrtc_all_ice` tells the peer). + /// When policy relay *and* TURN are configured, WebRTC via TURN is a valid "relayed" path: /// `connect` keeps a WebRTC win instead of replacing it with the RustDesk relay, and the /// RelayResponse path races it without a P2P preference delay. Any request carrying an offer /// keeps its rendezvous socket for trickle signaling and never reuses it for TCP punching; a @@ -616,7 +622,7 @@ impl Client { if Config::is_proxy() { return false; } - if interface.is_force_relay() && !WebRTCStream::has_turn_server() { + if interface.is_policy_relay() && !WebRTCStream::has_turn_server() { return false; } true @@ -883,6 +889,9 @@ impl Client { socket_addr_v6: ipv6.1.unwrap_or_default(), switch_code, webrtc_sdp_offer: webrtc_sdp_offer.clone(), + // Full-ICE offer despite force_relay (ws transport): tells the controlled side + // its answer may gather every candidate type instead of requiring TURN. + webrtc_all_ice: !webrtc_sdp_offer.is_empty() && !interface.is_policy_relay(), ..Default::default() }); let webrtc_session_key = webrtc_offerer @@ -1064,10 +1073,12 @@ impl Client { Ok((Stream::WebRTC(raced), None, "WebRTC")) } .boxed(); - if interface.is_force_relay() { + if interface.is_policy_relay() { // Relay-only WebRTC can use only TURN, so it has no P2P advantage // over the RustDesk relay. Take the first successful relay instead // of delaying an already-ready result for the preference window. + // Policy, not force_relay: under ws the offer is full ICE and a + // direct path is exactly what the preference window exists for. connect_futures.push(webrtc_fut); select_ok(connect_futures).await.map(|r| r.0) } else { @@ -1415,9 +1426,10 @@ impl Client { // disarmed only at the successful return when WebRTC is the kept transport. let mut direct = !conn.is_err(); - // A WebRTC win under force_relay only happens when the pc was built with Relay-only ICE - // (TURN configured), which already honors the relay requirement — keep it instead of - // replacing it with the RustDesk relay. + // Keep a WebRTC win instead of replacing it with the RustDesk relay: under relay-by- + // policy the pc was built with Relay-only ICE (TURN configured), which already honors + // the relay requirement, and under ws-forced relay a direct full-ICE connection is the + // preferred outcome, not a violation. if (interface.is_force_relay() && typ != "WebRTC") || conn.is_err() { if !relay_server.is_empty() { let switch_code = interface.get_switch_code(); @@ -2584,6 +2596,13 @@ pub struct LoginConfigHandler { // reconnect before the real reboot disconnect. restart_remote_device_at: Option, pub force_relay: bool, + // The policy component of force_relay: the user's relay choice (option or explicit + // request) plus proxy, WITHOUT the WebSocket-transport component. ws kills classic + // TCP/UDP punching (force_relay stays set for those paths) but says nothing about + // ICE, so WebRTC decisions - offer ICE policy, prefer-P2P racing - key off this + // instead: relay-by-policy must stay Relay-only ICE, relay-by-transport may go + // direct over full ICE. + pub policy_relay: bool, pub direct: Option, pub received: bool, switch_uuid: Option, @@ -2696,11 +2715,11 @@ impl LoginConfigHandler { self.session_id = sid; self.supported_encoding = Default::default(); self.clear_restarting_remote_device(); - self.force_relay = + self.policy_relay = config::option2bool("force-always-relay", &self.get_option("force-always-relay")) || force_relay - || use_ws() || Config::is_proxy(); + self.force_relay = self.policy_relay || use_ws(); if let Some((real_id, server, key)) = &self.other_server { let other_server_key = self.get_option("other-server-key"); if !other_server_key.is_empty() && key.is_empty() { @@ -4592,6 +4611,10 @@ pub trait Interface: Send + Clone + 'static + Sized { self.get_lch().read().unwrap().force_relay } + fn is_policy_relay(&self) -> bool { + self.get_lch().read().unwrap().policy_relay + } + fn get_switch_code(&self) -> String { match self.get_lch().read().unwrap().switch_uuid.clone() { Some(u) if !u.is_empty() => { diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index f5d51525b..3db47f7d3 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -689,13 +689,13 @@ impl RendezvousMediator { async fn spawn_webrtc_answerer( &self, ph: &PunchHole, - force_relay: bool, + relay_only_ice: bool, server: ServerPtr, peer_addr: SocketAddr, meta: ConnectionMeta, ) -> ResultType { let mut stream = - WebRTCStream::new(&ph.webrtc_sdp_offer, force_relay, CONNECT_TIMEOUT).await?; + WebRTCStream::new(&ph.webrtc_sdp_offer, relay_only_ice, CONNECT_TIMEOUT).await?; let answer = match stream.get_local_endpoint_trickle().await { Ok(answer) => answer, Err(e) => { @@ -849,14 +849,18 @@ impl RendezvousMediator { // and STUN bypass the proxy and leak the real IP. WebSocket mode does NOT disable it — // ws only tunnels the signaling/relay legs to the server, classic punching stays forced // to relay (`relay` above), and the answer rides the RelayResponse, leaving ICE as the - // only P2P path there. force_relay is different: relay-only ICE is viable with TURN. + // only P2P path there. force_relay depends on why it was set: with webrtc_all_ice the + // controller's relay is transport-forced (ws) and its offer carries every candidate + // type, so answer with full ICE and let a direct pair form; without it the offer is + // Relay-only ICE by policy, viable (and answerable) only through TURN. + let webrtc_relay_only = ph.force_relay && !ph.webrtc_all_ice; let webrtc_viable = !ph.webrtc_sdp_offer.is_empty() && !Config::is_proxy() - && (!ph.force_relay || WebRTCStream::has_turn_server()); + && (!webrtc_relay_only || WebRTCStream::has_turn_server()); let webrtc_sdp_answer = if webrtc_viable { self.spawn_webrtc_answerer( &ph, - ph.force_relay, + webrtc_relay_only, server.clone(), peer_addr, meta.clone(), diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 03b59a497..f7ca0e083 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1294,7 +1294,11 @@ impl Session { // override only if true if true == force_relay { - self.lc.write().unwrap().force_relay = true; + let mut lc = self.lc.write().unwrap(); + lc.force_relay = true; + // An explicit retry-via-relay is user policy, not transport necessity: keep + // WebRTC on Relay-only ICE for this round like any force-always-relay session. + lc.policy_relay = true; } self.lc.write().unwrap().peer_info = None; self.reconnect_count.fetch_add(1, Ordering::SeqCst);