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.
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
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
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
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
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
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
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
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
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
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
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
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
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
`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
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
`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
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
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
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
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
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
`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
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
hbb_common is shared with the server, but `fs`, `platform`, `keyboard`,
`message.proto` and most of `config::keys` are reached only from the client.
Keeping them here means every change to them costs a submodule round-trip.
They move to the new `base` crate in the rustdesk tree. What stays is
what the server actually names: `rendezvous.proto`, the socket/stream layer, the
`Config` core, and the 32 option keys this crate reads itself. Those 32 are
re-exported from `base::config::keys`, so callers still see one set.
`IdPk` moves from message.proto to rendezvous.proto. It is register-pk handshake
business, the server is its only other user, and the field numbers are unchanged
so the wire format is identical.
`config::patch()` resolves a home directory through the shell lookup that lived
in `platform::linux`, so `find_cmd_path`, `CMD_*` and `run_cmds_trim_newline`
stay behind in a new `sh` module. `base::platform::linux` re-exports
them, leaving every existing `platform::linux::CMD_SH` path working.
backtrace, filetime, osascript, smithay-client-toolkit and winapi go with the
code that used them, so the server no longer builds a Wayland client toolkit and
a Windows API crate it never calls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
Racing WebRTC against UDP and TCP means one transport is cancelled on every
session that connects, and cancelling it walks agent_internal's candidate list
closing sockets that are already gone - one warn per candidate, plus the
peer_connection cascade behind it (ice cancelled by caller, SCTP without DTLS,
the SRTP sessions never opened). Trickle adds pingAllCandidates on every check
tick until the first pair arrives. None of it is a fault, and all of it lands
beside a connection that succeeded, where it reads as one: 90 of 386 lines in a
controlled-side log spent saying the design worked.
agent_gather stays at warn. A STUN server it could not reach is the one thing
upstream says that explains a session which never connected, and it is a
different module from the noise, so the two separate cleanly. Errors in the
quieted modules still print, and RUST_LOG overrides the whole spec for anyone
who wants the warns back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M54JAqUK4RynudFou89hod
The local-candidate channel's only sender lived in the on_ice_candidate
handler until the pc reached a terminal state, so the forwarder loop a
caller writes on take_local_ice_rx sat parked on recv() for the whole
session. On the controlled side that loop holds its dedicated signaling
connection to hbbs, which hbbs closes after 90s idle, leaving a task and
a CLOSE_WAIT socket per session until the session ended. Gathering
complete is the agent's last word short of an ICE restart, which nothing
here does, so drop the sender there; the terminal-state handler's swap
stays for a pc that dies before it gets that far.
Reassembly handed the accumulated message over with mem::take, which
left the accumulator with no capacity and had every fragmented frame
regrow it from nothing, reallocating and recopying as it went. split()
hands over the same bytes and keeps the allocation, which reserve()
reclaims once the caller has dropped the message.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
Reads a `webrtc://` offer from stdin, answers it, bridges ICE candidates
as JSON lines over stdin/stdout and echoes every message. rustdesk-web-js
drives it from Chrome (js/e2e/webrtc) to check that the browser transport
and WebRTCStream still agree on the envelope, the signed fingerprint, the
candidate JSON and the fragment framing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXJJGEGdgu26wgCppvUXdZ
`Url` returns the username and password percent-encoded, and they went
into the ICE server configuration as-is, so a password containing `@`,
`/`, `?`, `#`, `%` or a space (written encoded, the only way the URL can
carry it) failed the long-term credential check with a 401 and TURN
never produced a relay candidate. Decode both, as the web client does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXJJGEGdgu26wgCppvUXdZ
The data channel's SCTP association runs RFC 4960's AIMD: a fast retransmit
halves cwnd, a T3 drops it to one MTU, and on a path whose loss is random
rather than congestion that pins the rate to MSS/(RTT*sqrt(p)) - about
1.3 Mbps at 70ms RTT and 1% loss - however idle the link is. KCP has always
shipped its turbo profile (nc=1) with no congestion window, which is why the
same link streams over KCP and crawls over WebRTC.
The sctp fork now carries a process-wide switch that bypasses the two places
gating sends on cwnd. Turn it on unless `allow-webrtc-congestion-control` is
set: the same opt-in `allow-kcp-congestion-control` gives KCP, for the reason
at `get_kcp_cc_enabled`. It is re-read on every new peer connection, so a
changed option reaches the next session without a restart.
Sender-side only: nothing is negotiated, and a browser or an older build on
the other end interoperates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
Taken from upstream #589. The eviction in the state handler re-derived the cache key
from `pc.local_description()` while holding SESSIONS - an await under the lock, on a
webrtc-rs internal mutex, in the one callback that runs inline from `pc.close()`. The
identity scan needs no description: it is synchronous, and it already was the fallback
for the key-derivation failure, so the two paths collapse into one. The duplicate-offer
guard survives unchanged, because a scan by `Arc::ptr_eq` cannot touch the winner
sharing the key.
The data-channel guard is released before `close()` rather than held across it, so a
concurrent `detach()` on the same stream cannot stall behind the peer's teardown.
Upstream's `cache_or_reuse_session` and its deadlock test are not carried: the insert
path here goes through `NewStreamHandoff` and never closes a pc under the lock, so the
function had no caller and the test exercised nothing this tree can do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
Two of the three entries sat on one host, so they failed together - in the same millisecond,
on a peer whose route to that host was down. Spend the slots on separate networks instead:
two anycast, two unicast, and a :443 for the networks that pass no other UDP port. The note
about reading NAT type off two ports of one address goes with them - webrtc-ice queries each
URL from its own socket, so that comparison never held, and nothing consumes the result.
`stun_servers()` hands the STUN half out, so the IPv6 probe can stop keeping a second
hand-written copy and an operator's OPTION_ICE_SERVERS override reaches both paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
`option2bool` reads an `enable-` option as on unless it is literally "N", while this one's
accessor required a literal "Y" - the name promised default-on and the code shipped
default-off. `allow-` is the prefix whose rule matches the behaviour that already ships, so
the rename settles the contradiction without moving a single user's transport.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
The label a session is reported under names the transport that won the race, not the family
ICE ended up nominating, so a WebRTC session over IPv6 read the same as one over IPv4. Take
it from the remote side of the selected pair - the address the peer is actually reached at,
which the rendezvous-observed address the session is otherwise identified by cannot report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3