Repository navigation
feat(rdpeudp): MS-RDPEUDP version 1/2 reliable data transfer - #1919
Conversation
Every Windows host tested on a LAN answers the RDPUDP SYN with uUdpVer 0x0002: they speak MS-RDPEUDP (version 1/2), not MS-RDPEUDP2 (version 3). A client that only implements the version 3 framing therefore always times out on the UDP handshake and falls back to TCP, so the multitransport path never carries data against Windows. This adds the version 1/2 reliable data path to the sans-I/O connection state machine, negotiated from the SYN+ACK and reusing the existing send/receive windows, RTT estimator, loss detection and timers: - pdu: RDPUDP_SOURCE_PAYLOAD_HEADER (2.2.2.4) and the data payload on a version 1/2 Source Packet. DATA datagrams decode; FEC (lossy) datagrams are rejected. - connection: NegotiatedParams records the wire format. Version 1/2 uses a single Source sequence space from ISN+1; snCoded numbers each transmission, snSourceAck drives an ACK vector walked down from the highest received sequence (3.1.1.4), ACK-of-ACKs resets the receiver's vector base (2.2.2.6), a Congestion Notify is answered with CWR at most once per RTT (3.1.1.8), and ACKDELAYED marks timer-driven ACKs (3.1.6.3). Timers take the version's floors. - The 6-bit ACK-vector run length is encoded as count minus one on the wire, which MS-RDPEUDP 2.2.2.7.1 leaves ambiguous but Windows requires: element 0x00 acknowledges one Source Packet and 0x02 acknowledges three. Reading it literally leaves the first Source Packet unacknowledged forever, collapsing the congestion window. - seq: 32-bit wrap-aware sequence reconstruction and narrowing. - ConnectionConfig::offer_version lets a client offer version 2 or 1 outright (no cookieHash), which is what the measured hosts answer immediately. - RdpeudpConnection::v1_stats exposes retransmit, ACK and datagram counters for observability. Verified against the spec's 4.2.1 example capture and new state-machine tests; the existing tests are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Greg Lamberson (glamberson)
left a comment
There was a problem hiding this comment.
I think there's an off-by-one in the ACK vector run-length encoding here. connection.rs:2131-2132 reads:
Windows encodes a run of n datagrams as n - 1 (an element of 0x00 acknowledges exactly one), which 2.2.2.7.1's prose leaves open.
MS-RDPEUDP 2.2.2.7.1 says the six least significant bits "specify the length of a continuous sequence of datagrams," and all three of the spec's own worked examples (4.2.1, 4.2.2, 4.2.3) gloss the same byte the same way: raw value 4 is "length of the vector, 4 datagrams received," a direct literal reading with no minus-one. FreeRDP's Wireshark dissector (tools/wireshark/rdp-udp.lua) reads it the same way: local rleLen = bit32.band(b, 0x3f), no adjustment.
This crate's own pre-existing enqueue_final_ack (unchanged by this PR) sends V1AckVectorElement { length: 1, .. } to acknowledge exactly one datagram, a literal encoding. That's the opposite of what this PR's new code assumes, so after this merges the same field means two different things depending on which code path built it.
The 4.2.1-capture test added here (SourcePayloadHeader round-trip) doesn't actually exercise the ACK vector byte from that same example, only the header bytes next to it. Round-tripping 0x04 from that capture through build_v1_ack_parts/process_v1_acknowledgement would show 4 datagrams going in and 5 coming out.
If I'm wrong about this and there's a real packet capture showing Windows requiring the minus-one reading, I'd like to see it, since it contradicts the spec text, all three of its own examples, and FreeRDP's independent implementation. As written, process_v1_acknowledgement's 0..=element.length will count one more datagram as acknowledged than the peer actually confirmed, which on a lossy link means retiring a send-window entry the peer never received.
| for element in &vector.elements { | ||
| // Windows encodes a run of n datagrams as n - 1 (an element of 0x00 | ||
| // acknowledges exactly one), which 2.2.2.7.1's prose leaves open. | ||
| for _ in 0..=element.length { |
There was a problem hiding this comment.
This counts one more datagram than the peer confirmed, for the reasons in the review body above.
| VectorElementState::DatagramNotYetReceived | ||
| }, | ||
| // Wire length is count - 1; see process_v1_acknowledgement. | ||
| length: chunk - 1, |
There was a problem hiding this comment.
This is the encode side of the same issue.
| } else { | ||
| VectorElementState::DatagramNotYetReceived | ||
| }, | ||
| length: n - 1, // wire runs are count - 1 |
There was a problem hiding this comment.
Test fixtures bake in the same convention, which is why the suite doesn't catch it against the spec's own example.
…ture test The client's final ACK still encoded its single run (the SYN+ACK) as 1 while build_v1_ack_parts encodes a run of one as 0. Windows tolerated the extra sequence number, but the same field meant two different things depending on which path built it. Encode it as 0. Add a regression test that replays ACKs captured from Windows Server through process_v1_acknowledgement: after one Source Packet the server answered snSourceAck 1 with element 0x00, after four it answered snSourceAck 4 with element 0x03, and in both cases it had acted on every packet (the TLS handshake completed). Read literally those would be an empty run and a run that leaves seq 1 unacknowledged forever; the six-bit run length is count - 1. Document that on V1AckVectorElement::length. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the close read. The minus-one encoding is measured Windows behaviour, not a guess. Capture and byte-level decode here: https://gist.github.com/AKolenda/51931b094dd454ef0eb1430503629c8a Short version, from a session against Windows Server (MS-RDPEUDP protocol version 2, which uses the same FEC-header framing and 2.2.2.7.1 ACK vector). After our first Source Packet (seq 1, the TLS ClientHello) the ServerHello came back with On the sources: the 2.2.2.7.1 prose never gives the numeric mapping, and 4.2.1/4.2.2/4.2.3 show the 0x04 byte without the peer's sequence numbers, so they cannot distinguish 4 from 5. The You are right that |
|
Thanks for the detailed reply and the capture. That's genuinely convincing evidence. I want to flag something for the record, though: MS-RDPEUDP's own worked example in section 4.2.3 explicitly reads a single ACK vector element with value 0x04 as "4 datagrams received," literally, with no minus-one adjustment. That's Microsoft's own annotation on their own example, and it directly conflicts with the count-minus-one convention your capture demonstrates and the fix now encodes. I traced the actual walk in But the premise is where the two sources disagree, and that conflict shouldn't pass silently. A live capture against a real Windows Server is stronger evidence than a documentation example (Microsoft's open specs have known errata, and the literal reading reproduces the exact stall this PR fixes), so I'd trust your fix over the worked example here. Might be worth a line in the PR body noting the discrepancy, for whoever reads section 4.2.3 next and wonders why the code doesn't match it. |
|
Agreed on all counts, and thanks for tracing the walk. I've added a note to the PR body: 2.2.2.7.1's prose gives no numeric mapping, 4.2.3's annotation reads 0x04 as four datagrams literally, and Windows Server on the wire encodes a run of n as n-1 (0x00 acknowledges one). The code follows the wire, with the capture linked as the evidence, so readers of 4.2.3 know why the two differ. |
|
I believe on all counts based on the back and forth that we have, I think we're ready to merge. Maintainers agree? |
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Agreed; I started the CI workflow and Copilot review. I’ll likely merge regardless, but I would appreciate follow up PRs if relevant. Thank you!
|
Looks like this is breaking the testsuite crates compilation. |
There was a problem hiding this comment.
🟡 Changes recommended
ACK-state retirement, flow control, congestion signaling, and malformed-packet handling contain protocol-breaking issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
crates/ironrdp-rdpeudp/src/connection.rs:2243
- Truncating the vector from the highest-sequence end drops the oldest runs while
snSourceAckremains the overall highest sequence. With more than 32 alternating runs, an old received packet is never represented, so the sender keeps it pending and cannot send an ACK-of-ACKs past it. Encode a complete representable range or constrain the V1 receive window so its full worst-case vector fits; do not silently omit the low end.
if elements.len() >= V1_ACK_VECTOR_MAX_ELEMENTS {
break 'runs;
crates/ironrdp-rdpeudp/src/connection.rs:2303
commit_acknowledgement()releases the leading receive-window slots as soon as this ACK is encoded. In MS-RDPEUDP, the peer's ACK-of-ACKs is what resets the ACK-vector base; if this ACK is lost, the retransmitted Source Packet is now belowbase_seq, is discarded as a duplicate, and can never be acknowledged. Retain V1 reception state untilprocess_v1_ack_of_acksadvances it.
self.commit_acknowledgement();
crates/ironrdp-rdpeudp/src/connection.rs:2128
- This ACK path uses
sn_source_ackbut ignoresheader.receive_window_size, and the send path gates only on the localSendWindowand congestion window. MS-RDPEUDP 3.1.5.3.2.1 permits a new Source Packet only when there is space in the receiver-advertised window, so a peer advertising a smaller or zero window will discard packets this connection continues sending. Track the remote window from SYN+ACK/ACK headers and gate new V1 Source Packets against it.
let reference = send_window.next_channel_seq().saturating_sub(1);
let highest_seen = seq::reconstruct_seq32(header.sn_source_ack, reference);
crates/ironrdp-rdpeudp/src/connection.rs:2142
- A decoded ACK vector may contain 2,048 elements, each representing 64 sequences, and every represented sequence performs a fresh linear scan of up to 32,768 pending entries. A small untrusted datagram can therefore trigger billions of comparisons and block the sans-I/O caller. Validate the represented span against the negotiated window and use a direct source-sequence lookup or a single ordered walk.
if element.state.is_received() {
if let Some(entry) = send_window.pending_entries().find(|e| e.channel_seq == source_seq) {
acked.push((entry.data_seq, entry.transmit_count == 1, entry.sent_at));
crates/ironrdp-rdpeudp/src/connection.rs:2202
- A reset reconstructed ahead of
highest_seqmakesadvance_basediscard all current state and set the base into the future, after which legitimate Source Packets are permanently below the window.snResetSeqNumis the sender's greatest cumulative ACK and cannot validly exceed the highest received sequence, so ignore or reject such a reset.
let reference = recv_window.highest_seq();
let reset = seq::reconstruct_seq32(ack_of_acks.reset_seq_num, reference);
recv_window.advance_base(reset.saturating_add(1));
crates/ironrdp-rdpeudp/src/connection.rs:2270
- No V1 ACK construction path ever sets
RDPUDP_FLAG_CN, even when receive-side loss is detected. MS-RDPEUDP requires the receiver to generate congestion notifications for detected losses and continue until the corresponding CWR arrives; without this feedback the peer does not reduce its send rate. Add receiver-side CN state/loss detection and include the flag until a matching CWR is observed.
let mut flags = flags | V1Flags::ACK;
if self.v1_ack_delayed {
flags |= V1Flags::ACKDELAYED;
}
crates/ironrdp-rdpeudp/src/connection.rs:2183
- The time-only suppression does not remember which Source Packet carried CWR. The protocol requires stale CNs whose
snSourceAckprecedes that CWR packet to be ignored; if the CWR is lost, the same old CN can currently reduce the congestion window again after one RTT. Pass the acknowledged source sequence into this path, record the sequence used for CWR, and filter stale CNs by that sequence.
let rtt = self.rtt.srtt().unwrap_or_else(|| self.effective_rto());
let recently = self
.v1_last_cn_reaction
.is_some_and(|last| now.duration_since(last) < rtt);
- Files reviewed: 9/9 changed files
- Comments generated: 5
- Review effort level: Balanced
| let wire = if syn_data_ex.udp_ver.uses_v2_wire_format() { | ||
| WireFormat::V2 | ||
| } else { | ||
| WireFormat::V1 { | ||
| version: syn_data_ex.udp_ver.0, | ||
| } |
| /// Version 1/2 Source Packet data (2.2.2.4), present when `RDPUDP_FLAG_DATA` is set. | ||
| pub data: Option<SourceData>, |
| "V1 Datagram", | ||
| "flags", | ||
| "FEC flag is not supported in handshake datagrams" | ||
| "FEC packets belong to lossy transport, which is not supported" |
| } | ||
|
|
||
| impl SourcePayloadHeader { | ||
| pub const FIXED_PART_SIZE: usize = 4 + 4; |
| /// Widens a 32-bit on-the-wire sequence number (MS-RDPEUDP 3.1.1.2) to the | ||
| /// 64-bit value closest to `reference`, so wrap-around is handled. | ||
| pub fn reconstruct_seq32(wire: u32, reference: u64) -> u64 { |
…K walk, ACK-of-ACKs The workspace integration tests in ironrdp-testsuite-core build V1Datagram and ConnectionConfig by hand and were not updated for the new `data` and `offer_version` fields, which broke `cargo xtask check tests`. Fill them in. The one test that asserted a client rejects a SYN+ACK settling on version 2 described the behaviour this change replaces; it now asserts the client follows the server down onto version 2 (MS-RDPEUDP 1.7) and that a version above the SYN's offer is refused. Review follow-ups: * handle_syn_ack accepted any version: an unknown value fell through to MS-RDPEUDP framing and V1 timers, and a version above what the SYN offered was taken at face value. 3.1.5.1.1 makes the SYN+ACK's version the highest supported by both endpoints, so reject anything other than 1, 2, or 3, and anything above ConnectionConfig::offer_version. * process_v1_acknowledgement scanned the send window afresh for every sequence an ACK vector names. A vector can name 2048 * 64 sequences, so one small datagram could cost span * window comparisons. Index the pending entries once and stop the walk at the lowest pending sequence. * process_v1_ack_of_acks applied a reset ahead of the highest sequence received, which pushed the window base into the future and stranded every later Source Packet below it. snResetSeqNum is something the peer has seen us acknowledge, so a value above highest_seq is dropped. * Style: name the addends of SourcePayloadHeader::FIXED_PART_SIZE, start the FEC rejection message in lowercase, move reconstruct_seq32 and truncate_seq32 above the seq.rs test module and merge their test into it, correct the seq.rs module doc (MS-RDPEUDP sequence numbers are 32-bit), and update the V1Datagram docs that still said version 1/2 data was not represented. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Sorry about the testsuite break; 098d801 fixes it. Copilot review, same commit:
Deferred to follow-up PRs, as suggested above, because each changes on-the-wire behaviour I want to validate against Windows before landing:
Benoît Cortier (@CBenoit) the workflow will need re-approving for the new commit. |
8fd2b2e
into
Devolutions:master
…ting-session-default * upstream/master: (22 commits) ci(agents): sync shared review skills (Devolutions#1973) [DEVOPS-4678] chore(ci): drop the Artifactory cache refresh step (Devolutions#1972) feat(activex): present EGFX dirty regions (Devolutions#1874) fix(pdu): add missing error info codes (Devolutions#1966) ci(pr-automation): hand off after a clean review (Devolutions#1959) ci(pr-automation): link resolved PR in run summary (Devolutions#1960) ci(pr-automation): show automation mode in the run name (Devolutions#1958) ci(pr-automation): make overlap detection advisory (Devolutions#1957) feat(server): add ConnectionPolicy to reject a second connection (Devolutions#1913) feat(rdpeai): add RdpeaiServer orchestration for AUDIO_INPUT (Devolutions#1945) ci(pr-automation): clarify review repair failures (Devolutions#1956) ci(pr-automation): clarify workflow name and run title (Devolutions#1950) ci(pr-automation): delay maintainer handoff (Devolutions#1937) ci(pr-automation): reserve turns a repair can spend (Devolutions#1939) fix(egfx): do not track frames a suspended client cannot acknowledge (Devolutions#1912) feat(rdpeudp): MS-RDPEUDP version 1/2 reliable data transfer (Devolutions#1919) fix(viewer): map native physical keycodes (Devolutions#1929) ci(pr-automation): classify after final review (Devolutions#1936) fix(egfx): decode AVC420 with full-range BT.709 (Devolutions#1923) test(core): add real-world progressive RemoteFX difference fixtures from Haven (Devolutions#1848) ... # Conflicts: # crates/ironrdp-server/src/builder.rs # crates/ironrdp-server/src/server.rs
Every Windows host tested on a LAN answers the RDPUDP SYN with uUdpVer 0x0002: they speak MS-RDPEUDP (version 1/2), not MS-RDPEUDP2 (version 3). A client that only implements the version 3 framing therefore always times out on the UDP handshake and falls back to TCP, so the multitransport path never carries data against Windows.
This adds the version 1/2 reliable data path to the sans-I/O connection state machine, negotiated from the SYN+ACK and reusing the existing send/receive windows, RTT estimator, loss detection and timers:
Verified against the spec's 4.2.1 example capture and new state-machine tests; the existing tests are unchanged.
Note on MS-RDPEUDP 4.2.3. The spec's worked example annotates ACK vector element 0x04 as "4 datagrams received," a literal reading of the six-bit run length. Captures against Windows Server show the field is the run length minus one: after one Source Packet the server answers
snSourceAck = 1with element 0x00, after four it answerssnSourceAck = 4with 0x03. Read literally, the first is an empty run and the second never acknowledges seq 1, which stalls the sender. This implementation follows the wire, not the example. Evidence: https://gist.github.com/AKolenda/51931b094dd454ef0eb1430503629c8a