diff --git a/crates/ironrdp-pdu/src/rdp/autodetect.rs b/crates/ironrdp-pdu/src/rdp/autodetect.rs index ad81f02f3..1987ae4d9 100644 --- a/crates/ironrdp-pdu/src/rdp/autodetect.rs +++ b/crates/ironrdp-pdu/src/rdp/autodetect.rs @@ -956,6 +956,15 @@ mod tests { 0x14, 0x00, 0x00, 0x00, // averageRTT = 20 ]; + const NETCHAR_RTT_WIRE: &[u8] = &[ + 0x0E, // headerLength + 0x00, // headerTypeId + 0x07, 0x00, // sequenceNumber = 7 + 0x40, 0x08, // requestType = NETCHAR_RESULT_RTT (0x0840) + 0x08, 0x00, 0x00, 0x00, // baseRTT = 8 + 0x12, 0x00, 0x00, 0x00, // averageRTT = 18 + ]; + #[test] fn decode_rtt_request() { let pdu = ironrdp_core::decode::(RTT_REQUEST_WIRE).unwrap(); @@ -1098,6 +1107,27 @@ mod tests { assert_eq!(encoded.as_slice(), NETCHAR_ALL_WIRE); } + #[test] + fn decode_netchar_rtt() { + let pdu = ironrdp_core::decode::(NETCHAR_RTT_WIRE).unwrap(); + match pdu { + AutoDetectRequest::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + } => { + assert_eq!(sequence_number, 7); + assert_eq!(request_type, NETCHAR_RESULT_RTT); + assert_eq!(base_rtt_ms, Some(8)); + assert_eq!(bandwidth_kbps, None); + assert_eq!(average_rtt_ms, 18); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } + } + #[test] fn request_round_trip() { let cases = vec![ diff --git a/crates/ironrdp-server/src/autodetect.rs b/crates/ironrdp-server/src/autodetect.rs index cf18bd4c7..cb627d9fb 100644 --- a/crates/ironrdp-server/src/autodetect.rs +++ b/crates/ironrdp-server/src/autodetect.rs @@ -16,6 +16,24 @@ const RTT_WINDOW_SIZE: usize = 8; /// Probes older than this are discarded as unresponsive. pub(crate) const RTT_PROBE_MAX_AGE_MS: u64 = 30_000; +/// Run a bandwidth measurement once every this many RTT ticks. Bandwidth +/// changes far more slowly than RTT and each measurement sends a payload, so it +/// is sampled less often than the per-tick RTT probe. +const BW_MEASURE_INTERVAL_TICKS: u32 = 8; + +/// Minimum spacing between Network Characteristics Result PDUs. +/// +/// The reported figures are averaged over a window and change far more slowly +/// than the RTT probe cadence, so pacing the result independently keeps an +/// aggressive probe rate from turning into an equally aggressive stream of +/// unsolicited PDUs. +const NETCHAR_RESULT_MIN_INTERVAL_MS: u64 = 1_000; + +/// Size of the synthetic Bandwidth Measure Payload, in bytes. Large enough that +/// the client's measured time delta is meaningful on a real link, small enough +/// not to be a noticeable periodic cost. +const BW_PAYLOAD_LEN: usize = 8192; + /// Server-side auto-detect state machine. /// /// Tracks outstanding RTT probes and computes round-trip statistics from @@ -35,6 +53,30 @@ pub struct AutoDetectManager { /// Outstanding probes as `(sequence_number, sent_at_ms)`. pending_probes: Vec<(u16, u64)>, rtt_samples: VecDeque, + /// Sequence number of an in-flight bandwidth measurement, if any. A new + /// measurement is not started while one is outstanding. + pending_bw: Option, + /// Most recently measured bandwidth in kilobits per second. + bandwidth_kbps: Option, + /// Counts RTT ticks to pace bandwidth measurements (see [`BW_MEASURE_INTERVAL_TICKS`]). + bw_tick_count: u32, + /// When the last Network Characteristics Result was built, for pacing. + last_netchar_result_ms: Option, + /// Lowest RTT observed over the whole session, for the wire `baseRTT`. + /// + /// [MS-RDPBCGR] 2.2.14.1.5 defines that field as "the lowest detected + /// round-trip time" with no window scoping, so it is tracked separately from + /// the sliding window behind [`snapshot()`](Self::snapshot). A floor derived + /// from the window can rise once a low sample is evicted, which would make + /// the baseRTT / averageRTT pair unusable for the queueing-delay difference + /// a client computes from it. + min_rtt_ms: Option, + /// Set when a client response updates the measured figures, cleared when a + /// Network Characteristics Result reports them. + /// + /// Without it a client that stops answering leaves the last window values + /// being advertised indefinitely, since they never age out of `rtt_samples`. + measurement_is_fresh: bool, } impl AutoDetectManager { @@ -43,14 +85,21 @@ impl AutoDetectManager { next_sequence: 0, pending_probes: Vec::new(), rtt_samples: VecDeque::with_capacity(RTT_WINDOW_SIZE), + pending_bw: None, + bandwidth_kbps: None, + bw_tick_count: 0, + last_netchar_result_ms: None, + measurement_is_fresh: false, + min_rtt_ms: None, } } /// Generate an RTT Measure Request PDU for continuous detection. /// - /// The caller must encode and send the returned [`AutoDetectRequest`] as - /// a Share Data PDU on the IO channel. `now_ms` is recorded as the send time and - /// is what [`handle_response()`](Self::handle_response) measures against. + /// The caller must encode and send the returned [`AutoDetectRequest`] on + /// the MCS message channel, framed by a `SEC_AUTODETECT_REQ` security + /// header ([MS-RDPBCGR] 2.2.14.3). `now_ms` is recorded as the send time + /// and is what [`handle_response()`](Self::handle_response) measures against. pub fn send_rtt_request(&mut self, now_ms: u64) -> AutoDetectRequest { let seq = self.next_sequence; self.next_sequence = seq.wrapping_add(1); @@ -58,30 +107,123 @@ impl AutoDetectManager { AutoDetectRequest::rtt_continuous(seq) } - /// Process an RTT Measure Response from the client. + /// Build a Bandwidth Measure transaction (Start → Payload → Stop) when one + /// is due, or `None` otherwise. /// - /// Returns the measured RTT in milliseconds if the sequence number - /// matches an outstanding probe, or `None` if it was unexpected. - /// `now_ms` is the receipt time on the same clock passed to - /// [`send_rtt_request()`](Self::send_rtt_request). - pub fn handle_response(&mut self, response: &AutoDetectResponse, now_ms: u64) -> Option { - let AutoDetectResponse::RttResponse { sequence_number } = response else { + /// Paced to one measurement per [`BW_MEASURE_INTERVAL_TICKS`] calls and + /// suppressed while a prior measurement is still outstanding. The three PDUs + /// must be sent back-to-back on the MCS message channel; the client counts + /// the bytes received between Start and Stop and replies with a Bandwidth + /// Measure Results PDU, processed by [`handle_response()`](Self::handle_response). + pub fn build_bandwidth_measure(&mut self) -> Option<[AutoDetectRequest; 3]> { + self.bw_tick_count = self.bw_tick_count.wrapping_add(1); + if self.pending_bw.is_some() || !self.bw_tick_count.is_multiple_of(BW_MEASURE_INTERVAL_TICKS) { return None; - }; + } + let seq = self.next_sequence; + self.next_sequence = seq.wrapping_add(1); + self.pending_bw = Some(seq); + Some([ + AutoDetectRequest::bw_start_continuous(seq), + AutoDetectRequest::bw_payload(seq, vec![0u8; BW_PAYLOAD_LEN]), + AutoDetectRequest::bw_stop_continuous(seq), + ]) + } - let idx = self.pending_probes.iter().position(|(s, _)| *s == *sequence_number)?; - let (_, sent_at_ms) = self.pending_probes.remove(idx); + /// Build a Network Characteristics Result reporting the measured network. + /// + /// Returns `None` until the network has actually been characterised, which + /// means both an RTT sample and a completed bandwidth measurement. A result + /// carrying RTT alone reports part of the picture as though it were the + /// whole, so it is not sent; the same is true of the reference + /// implementations, which return early with no bandwidth figure to report. + /// + /// Also returns `None` when one was sent less than + /// [`NETCHAR_RESULT_MIN_INTERVAL_MS`] ago. The RTT probe cadence is set by + /// the caller and can be far faster than the rate at which the reported + /// figures meaningfully change, so the result is paced independently rather + /// than emitted once per probe. + /// + /// Finally, returns `None` unless a client response has updated the figures + /// since the last result. Samples do not age out of the window, so a client + /// that stops answering would otherwise leave the last values being + /// advertised forever. `snapshot()` still reports them, where staleness is + /// the embedder's to judge, but they are not put on the wire as a current + /// claim. + /// + /// `baseRTT` is the lowest RTT seen over the whole session, not the lowest in + /// the current window, so it never rises. `averageRTT` is the window average + /// and does track current conditions; the difference between the two is what + /// a client reads as queueing delay. + /// + /// Like [`send_rtt_request()`](Self::send_rtt_request), the caller sends the + /// returned PDU on the MCS message channel. The client does not reply to it. + pub fn build_netchar_result(&mut self, now_ms: u64) -> Option { + if !self.measurement_is_fresh { + return None; + } + let bandwidth_kbps = self.bandwidth_kbps?; + let base_rtt_ms = self.min_rtt_ms?; + let snapshot = self.snapshot()?; + + if let Some(last) = self.last_netchar_result_ms { + if now_ms.saturating_sub(last) < NETCHAR_RESULT_MIN_INTERVAL_MS { + return None; + } + } + self.last_netchar_result_ms = Some(now_ms); + self.measurement_is_fresh = false; - // Saturating rather than wrapping: a caller whose clock went backwards gets a - // zero sample, not a nonsense one near u32::MAX. - let rtt_ms = u32::try_from(now_ms.saturating_sub(sent_at_ms)).unwrap_or(u32::MAX); + let seq = self.next_sequence; + self.next_sequence = seq.wrapping_add(1); + Some(AutoDetectRequest::netchar_result( + seq, + base_rtt_ms, + bandwidth_kbps, + snapshot.avg_ms, + )) + } - if self.rtt_samples.len() >= RTT_WINDOW_SIZE { - self.rtt_samples.pop_front(); + /// Process an Auto-Detect Response from the client. + /// + /// For an RTT Measure Response, records the sample and returns the measured + /// RTT in milliseconds. For a Bandwidth Measure Results, records the + /// computed bandwidth internally and returns `None`. Returns `None` for an + /// unexpected or unmatched response. + /// + /// `now_ms` is the receipt time on the same clock passed to + /// [`send_rtt_request()`](Self::send_rtt_request). + pub fn handle_response(&mut self, response: &AutoDetectResponse, now_ms: u64) -> Option { + match response { + AutoDetectResponse::RttResponse { sequence_number } => { + let idx = self.pending_probes.iter().position(|(s, _)| *s == *sequence_number)?; + let (_, sent_at_ms) = self.pending_probes.remove(idx); + + // Saturating rather than wrapping: a caller whose clock went backwards gets + // a zero sample, not a nonsense one near u32::MAX. + let rtt_ms = u32::try_from(now_ms.saturating_sub(sent_at_ms)).unwrap_or(u32::MAX); + + if self.rtt_samples.len() >= RTT_WINDOW_SIZE { + self.rtt_samples.pop_front(); + } + self.rtt_samples.push_back(rtt_ms); + self.min_rtt_ms = Some(self.min_rtt_ms.map_or(rtt_ms, |m| m.min(rtt_ms))); + self.measurement_is_fresh = true; + + Some(rtt_ms) + } + AutoDetectResponse::BandwidthMeasureResults { sequence_number, .. } => { + if self.pending_bw == Some(*sequence_number) { + self.pending_bw = None; + if let Some(kbps) = response.computed_bandwidth_kbps() { + self.bandwidth_kbps = Some(kbps); + self.measurement_is_fresh = true; + } + } + None + } + _ => None, } - self.rtt_samples.push_back(rtt_ms); - - Some(rtt_ms) } /// Get current RTT statistics, or `None` if no measurements yet. @@ -132,7 +274,10 @@ impl Default for AutoDetectManager { /// Snapshot of RTT measurement results. #[derive(Debug, Clone, Copy)] pub struct RttSnapshot { - /// Minimum observed RTT in milliseconds. + /// Minimum RTT in milliseconds over the current window. + /// + /// Not the `baseRTT` sent on the wire, which is the session-lifetime lowest + /// per [MS-RDPBCGR] 2.2.14.1.5. This one can rise as low samples age out. pub min_ms: u32, /// Maximum observed RTT in milliseconds. pub max_ms: u32, diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 6ce6e54f5..5c0573f1c 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1468,6 +1468,27 @@ impl RdpServer { let request = ad.send_rtt_request(now_ms); let data = encode_autodetect_request(request, message_channel_id, user_channel_id)?; writer.write_all(&data).await?; + + // Report the measured characteristics to the client + // ([MS-RDPBCGR] 2.2.14.1.5). The client does not reply. Sent only + // once both RTT and bandwidth are known, and paced independently + // of this probe cadence, so a fast caller does not turn into a + // fast stream of unsolicited PDUs. + if let Some(result) = ad.build_netchar_result(now_ms) { + let data = encode_autodetect_request(result, message_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + + // Periodically measure bandwidth: Start → Payload → Stop sent + // back-to-back so the client counts only the payload window, then + // replies with a Bandwidth Measure Results PDU. Until one has + // completed there is no characteristics result to send at all. + if let Some(bw_pdus) = ad.build_bandwidth_measure() { + for pdu in bw_pdus { + let data = encode_autodetect_request(pdu, message_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + } } } } diff --git a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs index 54d795554..50ab4c1d2 100644 --- a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs +++ b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs @@ -1,4 +1,4 @@ -use ironrdp_pdu::rdp::autodetect::AutoDetectResponse; +use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; use ironrdp_server::autodetect::AutoDetectManager; #[test] @@ -60,6 +60,75 @@ fn snapshot_reflects_measurements() { assert_eq!(snap.avg_ms, 20); } +#[test] +fn netchar_result_none_without_measurements() { + let mut mgr = AutoDetectManager::new(); + assert!( + mgr.build_netchar_result(0).is_none(), + "no result should be produced before the network has been characterised" + ); +} + +#[test] +fn netchar_result_withheld_until_bandwidth_is_known() { + let mut mgr = AutoDetectManager::new(); + + for _ in 0..3 { + let req = mgr.send_rtt_request(0); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response, 20); + } + + assert!(mgr.snapshot().is_some(), "RTT samples were recorded"); + assert!( + mgr.build_netchar_result(20).is_none(), + "RTT without a bandwidth measurement is not a characterisation of the network" + ); +} + +#[test] +fn bandwidth_measure_transacts_and_enables_netchar() { + let mut mgr = AutoDetectManager::new(); + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 20, + ); + + // Drive a bandwidth measurement to completion (paced internally). + let pdus = loop { + if let Some(p) = mgr.build_bandwidth_measure() { + break p; + } + }; + assert_eq!( + pdus[0].sequence_number(), + pdus[2].sequence_number(), + "Start and Stop share the transaction sequence" + ); + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: pdus[0].sequence_number(), + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr + .build_netchar_result(20) + .expect("result once RTT and bandwidth are known") + { + AutoDetectRequest::NetworkCharacteristicsResult { bandwidth_kbps, .. } => { + assert_eq!(bandwidth_kbps, Some(80_000), "byte_count * 8 / time_delta_ms"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +} + #[test] fn sequence_number_wraps_at_u16_max() { let mut mgr = AutoDetectManager::new(); @@ -130,3 +199,218 @@ fn stale_probe_expiry() { mgr.expire_stale_probes(1, 0); assert_eq!(mgr.pending_count(), 0); } + +/// Mirrors the crate-private `NETCHAR_RESULT_MIN_INTERVAL_MS`, which is not +/// reachable across the crate boundary. +const NETCHAR_RESULT_INTERVAL_MS: u64 = 1_000; + +/// Mirrors the crate-private `RTT_WINDOW_SIZE`. +const RTT_WINDOW: usize = 8; + +/// The result is paced on its own clock, not on the probe cadence: a caller +/// probing far faster than the interval still emits at most one per interval. +#[test] +fn netchar_result_is_paced_independently_of_the_probe_cadence() { + let mut mgr = AutoDetectManager::new(); + + let req = mgr.send_rtt_request(0); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response, 20); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + assert!(mgr.build_netchar_result(1_000).is_some(), "the first one is due"); + + // Feed a fresh sample so only the interval is under test here. + let req = mgr.send_rtt_request(1_000); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 1_020, + ); + + assert!( + mgr.build_netchar_result(1_000 + NETCHAR_RESULT_INTERVAL_MS - 1) + .is_none(), + "a probe inside the interval does not emit another" + ); + assert!( + mgr.build_netchar_result(1_000 + NETCHAR_RESULT_INTERVAL_MS).is_some(), + "the interval having elapsed, the next one is due" + ); +} + +/// A client that stops answering must stop producing results. Samples never +/// age out of the window, so without this the last values would be +/// advertised forever. +#[test] +fn netchar_result_stops_when_the_client_stops_answering() { + let mut mgr = AutoDetectManager::new(); + + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 20, + ); + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + let mut now = 1_000; + assert!(mgr.build_netchar_result(now).is_some(), "the first one is due"); + + // The client has gone quiet. The interval keeps elapsing and the window + // still holds its samples, but there is nothing new to report. + for _ in 0..5 { + now += NETCHAR_RESULT_INTERVAL_MS; + assert!( + mgr.build_netchar_result(now).is_none(), + "no response has arrived since the last result" + ); + } + assert!(mgr.snapshot().is_some(), "the samples are still there for the embedder"); + + // One reply, and reporting resumes. + let req = mgr.send_rtt_request(now); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + now + 20, + ); + now += NETCHAR_RESULT_INTERVAL_MS; + assert!( + mgr.build_netchar_result(now).is_some(), + "a fresh sample resumes reporting" + ); +} + +/// [MS-RDPBCGR] 2.2.14.1.5 defines baseRTT as the lowest detected RTT, with no +/// window scoping, so it must not rise when a low sample ages out of the +/// window. This is the reviewer's sequence: one 5 ms sample, then eight of +/// 100 ms, which evicts it. +#[test] +fn base_rtt_is_the_session_low_not_the_window_low() { + let mut mgr = AutoDetectManager::new(); + + let sample = |mgr: &mut AutoDetectManager, rtt: u64| { + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + rtt, + ); + }; + + sample(&mut mgr, 5); + for _ in 0..RTT_WINDOW { + sample(&mut mgr, 100); + } + + assert_eq!( + mgr.snapshot().expect("samples recorded").min_ms, + 100, + "the 5 ms sample has aged out of the window" + ); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr.build_netchar_result(1_000).expect("RTT and bandwidth are known") { + AutoDetectRequest::NetworkCharacteristicsResult { base_rtt_ms, .. } => { + assert_eq!(base_rtt_ms, Some(5), "baseRTT stays at the lowest ever detected"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +} + +/// Pins which measured quantity lands in which wire field. +/// +/// The session minimum, the window minimum and the window average are all +/// different here, so no swap between them leaves both assertions holding. +#[test] +fn netchar_result_maps_each_measurement_to_its_own_field() { + let mut mgr = AutoDetectManager::new(); + + let sample = |mgr: &mut AutoDetectManager, rtt: u64| { + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + rtt, + ); + }; + + sample(&mut mgr, 5); + for rtt in [10, 20, 30, 40, 50, 60, 70, 80] { + sample(&mut mgr, rtt); + } + + let snap = mgr.snapshot().expect("samples recorded"); + assert_eq!(snap.min_ms, 10, "the 5 ms sample has left the window"); + assert_eq!(snap.avg_ms, 45, "average of 10 through 80"); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr.build_netchar_result(1_000).expect("RTT and bandwidth are known") { + AutoDetectRequest::NetworkCharacteristicsResult { + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + .. + } => { + assert_eq!(base_rtt_ms, Some(5), "baseRTT is the session low, not the window low"); + assert_eq!(average_rtt_ms, 45, "averageRTT is the window average"); + assert_eq!(bandwidth_kbps, Some(80_000), "byte_count * 8 / time_delta_ms"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +}