Commit Graph
469 Commits
Author SHA1 Message Date
RustDesk edefa08b79 Merge pull request #626 from rustdesk/custom-websocket-port
feat(websocket): add websocket-port for a proxy off the default port
2026-10-22 17:52:30 +08:00
rustdeskandClaude Opus 5.5 dfb9d0d1c6 fix(websocket): match websocket-port only against configured servers
websocket_port() took the rendezvous host from get_rendezvous_server(),
which falls back to a cached or public server when none is set. With
only relay-server and websocket-port set, the public rendezvous server
also got the port, and registration failed. Match the servers this
client is set up with instead: the exe-name host, custom-rendezvous-server,
the build-time server, and relay-server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:28:28 +08:00
rustdeskandClaude Opus 5.5 b3bf7b76e2 fix(websocket): throttle the invalid websocket-port warning
check_ws runs on every rendezvous reconnect, online query and relay
connect, so an invalid websocket-port logged a warning each time.
Keep one line a minute with a count of the rest. Also import the key
with the other config keys instead of in the function body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:04:26 +08:00
rustdeskandClaude Opus 5.5 34846eef9f feat(websocket): add websocket-port for a proxy off the default port
check_ws turns a domain endpoint into ws(s)://<domain>/ws/id or
/ws/relay with no port, so a reverse proxy that cannot take 443 (or 80)
is unreachable, even when api-server already points at it, for example
https://example.com:8443.

websocket-port adds that port for the hosts this client is configured
with: custom-rendezvous-server and relay-server. A relay that hbbs hands
out on another host, or a peer's server, keeps the default port, since
another machine has its own proxy. IP endpoints are unchanged, and an
empty option gives the same URL as before.

https://github.com/rustdesk/rustdesk-server-pro/discussions/1029

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:05:24 +08:00
RustDesk ed07431021 Merge pull request #620 from 21pages/suppress_log
fix: reduce noisy smithay client toolkit logs
2026-10-20 16:50:15 +08:00
21pages 20d5019a42 fix: reduce noisy smithay client toolkit logs 2026-10-16 17:35:46 +08:00
RustDesk 5d52530b63 Merge pull request #609 from AzimovIz/remoteUSB
Add REMOTE_USB connection type
2026-10-14 20:51:07 +08:00
RustDesk a63c5b2301 Merge pull request #600 from 21pages/websocket-proxy
websocket proxy support
2026-10-14 20:50:15 +08:00
21pages 423fb71085 fix: return local address for direct WebSocket connections 2026-10-14 20:04:48 +08:00
21pages 0ccaaf2cf6 fix: parse IPv6 HTTP proxy authorities correctly 2026-10-13 18:58:42 +08:00
21pages 2d998e7b02 fix: reuse the initial TCP connection for HTTPS proxies 2026-10-13 11:51:29 +08:00
21pages 4c04a7a130 fix: allow proxy reads while flushing is pending 2026-10-13 11:51:29 +08:00
21pages 19b8b2242d fix: always use configured proxy for WebSocket 2026-10-12 18:58:03 +08:00
21pages 9ee2f3ef9a fix: bracket IPv6 targets in HTTP CONNECT requests 2026-10-10 19:45:51 +08:00
21pages 3aade011c0 feat: add opt-in WebSocket proxy support
Support WS and WSS over SOCKS5, HTTP, and HTTPS proxies, including custom
ports. Use the configured proxy only when allow-websocket-proxy is enabled;
explicit proxy configuration takes precedence. Propagate proxy failures
without retrying a direct connection.

Add regression coverage for proxy routing, authentication, TLS, custom
ports, direct defaults, and failure handling.

Signed-off-by: 21pages <sunboeasy@gmail.com>

fix: flush WebSocket proxy tunnels before native TLS reads

Flush buffered HTTP and HTTPS tunnel writes before NativeTls reads so WSS handshakes can complete on macOS. Apply the adapter only to NativeTls WebSocket connections through HTTP or HTTPS proxies.

Extend the existing proxy round-trip test with HTTPS and SOCKS5 NativeTls cases. The websocket_proxy integration test passes in the rustdesk workspace.

Signed-off-by: 21pages <sunboeasy@gmail.com>

refactor: simplify WebSocket proxy configuration

Select the configured proxy inside WsFramedStream::new based on the WebSocket proxy option and remove the explicit proxy argument. Update the socket client and drop the unused Stream::connect_websocket wrapper.

Adapt integration tests to configured proxies and cover custom target ports and WebSocket close handling.
2026-10-10 11:50:09 +08:00
AzimovIzandClaude Opus 5.5 b4be9744bf Drop the REMOTE_USB connection type
USB forwarding now runs inside a normal remote desktop session
(rustdesk/rustdesk#16278), so it needs no connection type of its own. Only
ControlPermissions.Permission.usb remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: AzimovIz <sergo.p.987@gmail.com>
2026-10-09 13:44:15 +00:00
AzimovIzandClaude Opus 5.5 bee7c2458c Add REMOTE_USB connection type and usb control permission
Adds ConnType::REMOTE_USB for the RemoteUsb feature (rustdesk/rustdesk#16278),
following the same pattern as TERMINAL, and ControlPermissions.Permission.usb
so USB forwarding can be controlled centrally like the other permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: AzimovIz <sergo.p.987@gmail.com>
2026-09-26 13:12:59 +00:00
rustdeskandClaude Opus 5.5 94ec710894 Build the webrtc feature standalone against the rustdesk-org fork
src/webrtc.rs calls fork-only APIs (set_ice_max_binding_requests,
set_no_congestion_control), which only rustdesk's root [patch] supplied,
so building this crate on its own with --features webrtc failed with
E0425/E0599. Mirror the root patch here; it applies only when this crate
is the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-25 16:30:59 +08:00
RustDesk 229b904508 Merge pull request #614 from rustdesk/kx-v2
Kx v2
2026-09-23 14:48:27 +08:00
rustdeskandClaude Fable 5.1 42c77b9a3b tcp: pin the subkeys for an advertisement above the pick
With advertised and picked both 1, swapping their order in the
derivation leaves the vector unchanged. Advertised 3 with picked 1 is
what a client meets at a newer server, and tells the two apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-23 14:27:51 +08:00
rustdeskandClaude Opus 5.5 2849258460 tcp: pin the version 0 frame and the version 1 subkeys
The existing tests run both ends on the same code, so they pass even
if the derivation changes. Fixed bytes catch that: the version 0 frame
released peers send, and the same subkey vector hbbs's copy of this
code pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-23 13:20:36 +08:00
rustdeskandClaude Opus 5.5 cee8cf3dc3 stream: refuse a key exchange version set_negotiated_key does not know
Version 1 is matched explicitly and any other picked version is an
error, so adding a version without its arm here fails instead of
running as version 1. WebRTC takes only version 0, as both handshakes
already require.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-23 13:20:36 +08:00
rustdeskandClaude Opus 5.5 cbc8772d4a stream: set_negotiated_key, one place that maps the picked version to a key
Every handshake picked a version and then repeated the same choice: at
least 1 splits the key, 0 sets it as before. Stream now makes that choice
from the transcript it is already given, so callers make one call and a
later version is handled here rather than at each of them. Version 0
still goes through set_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-23 09:53:34 +08:00
rustdeskandClaude Fable 5.1 26582e163d tcp: KX_PARAMS_DOMAIN, the prefix under which KxParams is signed
The server signs its ephemeral key, IdPk records and KxParams with one
key, and a protobuf carries no type of its own: a signed IdPk parses as
a KxParams whose pk is the id and whose version is 0. The prefix keeps
the three apart; the client requires it before parsing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-22 17:59:11 +08:00
rustdeskandClaude Fable 5.1 e74a188f4f KeyExchange: sign KxParams, a structure, in place of a fixed byte string
The server's second signature covered "rdkx-ver" || key || version, a
layout that could take nothing more. It now covers KxParams, a message
holding the key as it went out and the advertised version, so anything
the server needs signed later is a field added to it, and a client that
does not know a field parses past it, as with IdPk on the peer side.
Field 3 keeps its number under the name signed_params.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-22 17:43:38 +08:00
rustdeskandClaude Fable 5.1 5194159858 kx: drop the advertisement echo
signed_version settles the server's version inside the key exchange, so
an echo of it on the messages after has nothing left to confirm. With it
go RendezvousMessage.kx_advertised, the check the transport ran on each
decrypted frame, and the tests of both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-22 17:22:01 +08:00
rustdeskandClaude Fable 5.1 e3452acf59 kx: comments say what the fields and functions are
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-22 15:02:44 +08:00
rustdeskandClaude Fable 5.1 d9f519e915 KeyExchange: signed_version, the server's signature over its version
The echo catches an advertisement lowered in the clear only once a tagged
frame arrives, so the initiator's first frame has already gone out under
the lower version by then. A server that signs its version instead sets
bit 255 of the ephemeral X25519 key inside the signed keys[0] and puts
sign("rdkx-ver" || key || version LE) here. RFC 7748 masks that bit, so
the box opens as before on either side, and a legacy server, whose keys
are canonical, never sets it. A client that sees the bit, or the field,
verifies before trusting the version, so the pair settles it at the
handshake rather than one frame pair later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-22 13:18:45 +08:00
rustdeskandClaude Fable 5.1 1266b56150 kx_advertised: every message after the exchange, not the first alone
The contract named the server's first message after the key exchange. That
frame sits at a position an attacker needs no key to find, so one who had
lowered the advertisement in the clear only had to drop it to be rid of
the check for the whole session. With every message carrying the echo,
being rid of it means dropping them all, which is a plain DoS.

The client already kept the check armed until an echo arrived; this is the
wording the server side is written to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-21 18:33:33 +08:00
rustdeskandClaude Opus 5 445486ccaa tcp: drop box_pk_of until something calls it
It has no caller here or in the servers; the responder that would keep
only the secret half does not exist yet, so it belongs to that change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-21 18:33:33 +08:00
rustdeskandClaude Opus 5 955621899e tcp: keep the advertisement check armed until an echo arrives
The check was spent on the first frame that decrypted, whatever it held.
A server tags the messages it chooses to, and a frame carrying no echo
reads as `echoed == 0`, which passes; on the way out it also disarmed the
check for good, so an attacker who rewrote the advertised version only had
to let one untagged frame through to be rid of the only thing that would
have caught it.

A zero now says nothing either way and leaves the check standing. Only a
non-zero echo settles the question, matching or refusing the stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-21 18:33:33 +08:00
rustdeskandClaude Fable 5.1 e272fede36 tcp: key exchange version 1, one stream key per direction
Version 1 of the key exchange splits the exchanged key into a subkey per
direction, the way Noise splits its cipher states after the handshake, and
leaves the nonce layout and the handshake messages as they are. Each subkey
is a keyed BLAKE2b over the handshake transcript: both ephemeral public keys,
the version the responder advertised and the one the initiator picked, so a
change to any of it in transit leaves the two sides with different keys. The
original scheme is version 0, which is also what an absent field means.

Negotiated through new fields old peers do not see: the side that speaks
first advertises the newest version it has (`KeyExchange.version` from the
server, `IdPk.kx_version` inside the signed identity from the controlled
side), the other side answers with the highest both have, and the pair runs
that. Any old peer on either end keeps today's stream byte for byte. The
controlled side's advertisement sits inside the signature; the server's is
repeated in `RendezvousMessage.kx_advertised` on its first message after the
exchange, inside the encrypted channel, and a client that was shown a
different value in the clear refuses the stream.

`Encrypt::new` is unchanged; `new_split`, `set_key_split` and
`check_kx_advertised` are added beside it, and `box_pk_of` for a responder
that kept only the secret half of its ephemeral pair. The tests pin the
version 0 output as well as the split and the echo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-21 18:33:33 +08:00
rustdeskandClaude Fable 5.1 d9895ff9cb websocket: tungstenite 0.29 from rustdesk-org, read buffer grown as bytes arrive
Upstream reserves a frame's whole declared length the moment its header
is in, bounded only by max_frame_size, so a header alone buys the
allocation it names: 128 KiB before a session is authorized and 16 MiB
after. The rustdesk-org tungstenite-rs fork on rustdesk-patches-0.29,
upstream v0.29.0, grows the read buffer one read_buffer_size chunk at a
time as the bytes actually arrive instead, and tokio-tungstenite moves to
its rustdesk-patches-0.29 branch to match, still one commit for
set_config. 0.27's read chunking comes with the move, so a large frame no
longer clears its whole grown buffer on every read.

No source change: Bytes payloads and the non-exhaustive config arrived in
0.26 and 0.25 and were already in use here, and 0.28's boxing of
Error::Http derefs at its one use. The read buffer keeps tungstenite's
128 KiB default, this side reading video frames and file blocks over a
handful of connections rather than small messages over thousands. The
workspace root patches tungstenite to the fork, where cargo reads
[patch], so this crate's copy and tokio-tungstenite's resolve to one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-21 17:58:32 +08:00
RustDesk 7dc56d69e0 Merge pull request #611 from fufesou/fix/websocket-check-encrypt
fix(websocket): reject text frames after encryption is enabled
2026-09-20 18:12:20 +08:00
fufesou 1f47a8ec92 fix(websocket): reject text frames after encryption is enabled
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-20 17:51:49 +08:00
rustdeskandClaude Fable 5.1 7a5ad5252e socket_client: only the controller's NAT test takes the resolver's address
`new_direct_udp_for` is back on `test_target`. The punch reply the
controlled side sends with it goes to the address it picks, and that reply
lost is a device online and unreachable - so it keeps the handshake that
proves the address, as the registration does. The controller's NAT test,
once per connection, is what could not afford the proof, a round trip
right before the connection it then opens to the same host; it alone takes
the resolver's preferred address, as `new_direct_udp_for_unverified`, and
a wrong one there costs that connection its UDP punch and nothing more.

That lookup is the first on the controller's connection path and the one
not under CONNECT_TIMEOUT, so it gets a second: a resolver that hangs
would otherwise hold the connection for its whole retry schedule before
`connect_tcp` even starts, and what a slower lookup loses is, again, only
the NAT test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-19 00:18:16 +08:00
rustdeskandClaude Fable 5.1 0bc83361d6 socket_client: the rendezvous registration keeps its proven address
f688d41 replaced the TCP handshake in `test_target` with the resolver's
first answer for every caller. The two are the same when an IPv6 route is
missing, or leads to nothing: a v6-only host gets the synthesized address
first either way, and the handshake sat out its second on a black hole and
fell back to that same first answer. They differ when the server refuses
IPv6 outright - an AAAA published for a daemon bound to 0.0.0.0, a firewall
that rejects - where the handshake failed fast and moved on to the A
record, and the resolver's order stays on the AAAA.

That matters on one path. `new_udp_for` and `rebind_udp_for` choose the
address the rendezvous registration lives on, and `start_udp` never leaves
UDP for TCP nor asks for a second candidate: a rebind after `MAX_FAILS2`
calls the same function and gets the same address. A self-hosted server
with such an AAAA would never see the controlled side register. Those two
keep the handshake, one round trip per server connection, under the old
name and its old behaviour. `new_direct_udp_for`, per connection and
against servers whose records are ours, takes the resolver's answer as
`resolve_target`: the round trip it was spared stays spared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-18 16:05:50 +08:00
rustdeskandClaude Fable 5.1 f688d4116c socket_client: resolve the UDP target, do not shake hands with it
`test_target` opened a TCP connection to the rendezvous server to learn
which address the kernel would use, then dropped it: a full handshake, one
round trip, on every connection the controller starts, before the one
`_start_inner` opens to the same host a moment later - and a second's wait
first wherever an IPv6 route led to nothing, before it fell back to the
resolver anyway.

The handshake stood in for a choice that had been made by hand: before
2022's NAT64 fix the code preferred an A record outright, which a v6-only
host cannot reach, and asking the server itself was the way to let the OS
decide. The resolver decides the same way. getaddrinfo orders what it
returns as RFC 6724 says, looking up a route and a source address for each
candidate as it goes, so the synthesized IPv6 comes first on a v6-only host
and IPv6 on a dual-stack one, the order the handshake would have tried
them in. Its first answer is the address, and the fallback branch is all
that is left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-18 15:40:50 +08:00
RustDesk 6e0d667b69 Merge pull request #608 from 21pages/suppress-logs
fix(logging): suppress hyper_util debug logs
2026-09-18 14:37:46 +08:00
21pages 24aa2f6947 fix(logging): suppress hyper_util debug logs 2026-09-18 11:04:40 +08:00
rustdeskandClaude Fable 5.1 f0f1548ba3 bound the fragmented WebSocket message too, and say what lowering the cap does not promise
The two WebSocket tests both stopped at max_frame_size: a header over the cap
and a single large frame after the cap is lifted. Dropping max_message_size
from set_max_packet_length left both passing, and that line is what refuses
two frames each under the cap that reassemble to a message over it - the other
way a peer buys the allocation. A test now sends exactly that, 12 KiB and a
12 KiB continuation against a 16 KiB cap, and fails when the line is dropped.

The doc on Stream::set_max_packet_length claimed that lowering the bound never
discards a message already being reassembled. That is the TCP codec, which
checks only on the header; WebRTC and a fragmented WebSocket message re-check
the bound on every fragment and end the message once it passes. The doc now
promises only what holds everywhere: set it before the first read, and do not
lean on lowering it to constrain a message in progress.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-17 17:25:10 +08:00
rustdeskandClaude Fable 5.1 e999dcea24 cover the message cap's lifecycle on WebSocket and WebRTC
The cap's header-only refusal was tested on the TCP codec and its reassembly
bound on WebRTC, but not on WebSocket, which is the transport where a frame
header buys an allocation, and neither transport had a test that lifting the
cap lets a large message through again - the half of the lifecycle a server
runs at authorization, and the one a forgotten restore would break silently.

Two WebSocket tests build a loopback from a raw TcpStream with no handshake,
write server-to-client frames by hand, and check that a header declaring one
byte over the cap is refused before any payload exists, and that a message
refused under a low cap arrives once the cap is lifted. A WebRTC test does the
same lift. Each fails when the corresponding half of set_max_packet_length is
made a no-op.

The doc on Stream::set_max_packet_length now says the one thing the bound
does not do: lowering it applies from the next frame on and does not discard
one whose decoding or reassembly has already begun, so it has to be set before
the first read of untrusted data. That is how the server uses it; the note is
there so nothing later treats it as a switch that can be thrown at any time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-17 16:29:55 +08:00
RustDesk 0eb175963c Merge pull request #606 from fufesou/fix/test-restore-global-options
fix(tests): restore global options
2026-09-17 16:14:15 +08:00
fufesou 248709d6c8 fix(tests): restore global options
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-17 14:48:56 +08:00
rustdeskandClaude Opus 5 68dcbe6adc cap the message an unauthenticated peer can make us hold
A frame header declares its own length, and until a connection authorizes nothing
bounded that length but the transport's ceiling: 1 GiB on TCP and on WebRTC, 64 MiB
on WebSocket. The decoder here already refuses to preallocate on a declared length,
so on TCP an attacker spends a byte sent per byte held; tungstenite does not, and
reserves a frame's whole declared payload the moment it passes max_frame_size, so a
fourteen-byte header buys 16 MiB. That is CVE-2021-32675's shape, and Redis answered
it the same way: a bound that holds only until the peer has authenticated.

`Stream::set_max_packet_length` carries such a bound to whichever transport is under
it. TCP has the knob already. WebRTC gains one: its reassembly ceiling moves from a
constant to a per-stream atomic, shared by every clone because the receive path runs
off one, and clamped to that constant so the send path's check still describes what a
peer will accept. WebSocket reaches tungstenite's own config, which needs a fork -
upstream exposes get_config and no set_config, at v0.26.2 and still at 0.30.0 - and
moves both bounds: max_frame_size is what stops a header from buying an allocation,
max_message_size is what bounds reassembly. Every transport clamps to its own
ceiling, so usize::MAX restores exactly what was there and no caller can raise one
above what it always had.

Nothing here calls it, so on its own this changes no behaviour.

Tests cover both new paths: the codec takes a header at the cap and dies on the one
a byte above it, before any payload is buffered; and a WebRTC loopback pair whose
receiver has lowered its bound refuses an oversize message, while the sender, whose
own bound is untouched, still puts it on the wire the way an unauthenticated peer
would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-17 11:59:52 +08:00
rustdeskandClaude Fable 5.1 daf84c2df3 webrtc: cap the answerer's ICE binding requests at 74 (~15s)
The ICE crate marks a candidate pair failed after 7 unanswered binding
requests (~1.4s at the 200ms check interval) and stops sending on it.
Behind an address-restricted NAT those outgoing requests are also what
keep this side's mapping open to the remote's checks, so a browser peer
whose checks arrive late -- after the answerer has already given up --
never gets a response and the direct pair is lost while a relay carries
the session.

Set max_binding_requests to 74 (~14.8s, just inside libwebrtc's 15s
CONNECTION_WRITE_TIMEOUT) on the answerer only (remote_endpoint set), so
the controlled side keeps checking for about as long as a Chrome
controller does. The desktop offerer keeps the crate default as an
unchanged comparison point. Needs the SettingEngine knob added in
rustdesk-org/webrtc @ 80d5a205 (rustdesk-patches-0.13).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcJZZeJ3Nqb2MHxXuUadkb
2026-09-15 14:47:33 +08:00
RustDesk 29cf7cbe4d Merge pull request #599 from rustdesk/dead-peer-recovery
webrtc: report receive progress and ICE's disconnected hint
2026-09-09 17:39:13 +08:00
rustdeskandClaude Opus 5 6db01ec2f6 review: name the state in the health-hint assert
hbb_common is edition 2018, where a lone string literal is not treated as a
format string, so the capture printed itself and the loop over the two
terminal states could not say which one had failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-09 16:38:02 +08:00
rustdeskandClaude Opus 5 166bb3d2ac webrtc: do not hand out a cached session ICE has lost the peer on
`is_reusable_for` rejected a closed session but not one ICE currently reports
Disconnected. Nothing else does either - that state is deliberately not part of
the connection-state enum - so a caller could be given a session that already
carries the hint, and start off suspecting a peer it never used.

Also drops a race from the receive-progress test: it raced `next()` against a
sleeping sibling in a select, so a transfer that landed inside one poll left the
assertion looking false, and a slow enough one exhausted the loop and failed
with a misleading message. It now reads the counter after a `next_timeout` that
returned nothing, which is a point where the message is known to be incomplete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-08 16:52:28 +08:00
rustdeskandClaude Opus 5 c50c1fef32 webrtc: report receive progress and ICE's disconnected hint
A peer that is killed, logged out or rebooted sends no reset over UDP, so a
session only learns of it by timing out. Nothing here acts on that; these are
the two observations a caller needs to.

`rx_progress` counts arriving fragments. A message reaches the caller only once
it is whole, so a large one - a clipboard image is the case that occurs - leaves
the receive loop with nothing to observe for as long as the transfer takes, and
a peer sending steadily looks exactly like one that died. A counter, not a
timestamp: `Instant::now()` costs an order of magnitude more per read, and
callers only compare successive samples.

`webrtc_disconnected` exposes the ICE state webrtc-rs already raises ~5s after
it stops hearing from the peer. It is deliberately not a fourth
`WebRTCConnectionState`: that enum gates send and receive readiness and session
cache admissibility, and a transient state must change neither.

TCP and WebSocket answer for neither, on purpose: this change leaves their
existing close, error and timeout behaviour exactly as it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-08 14:57:09 +08:00