Skip to main content

fips_core/proto/mmp/
mod.rs

1//! Metrics Measurement Protocol (MMP) — link-layer instantiation.
2//!
3//! Measures link quality between adjacent peers: RTT, loss, jitter,
4//! throughput, one-way delay trend, and ETX. Operates on the per-frame
5//! hooks (counter, timestamp, flags) introduced by the FMP wire format
6//! revision.
7//!
8//! Three operating modes trade measurement fidelity for overhead:
9//! - **Full**: sender + receiver reports at RTT-adaptive intervals
10//! - **Lightweight**: receiver reports only (infer loss from counters)
11//! - **Minimal**: spin bit + CE echo only, no reports
12
13use serde::{Deserialize, Serialize};
14use std::fmt::{self, Debug};
15use std::time::{Duration, Instant};
16
17// Sub-modules
18pub mod algorithms;
19pub mod metrics;
20pub mod receiver;
21pub mod report;
22pub mod sender;
23
24// Re-exports
25pub use algorithms::{
26    DualEwma, JitterEstimator, OwdTrendDetector, SpinBitState, SrttEstimator, compute_etx,
27};
28pub use metrics::MmpMetrics;
29pub use receiver::ReceiverState;
30pub use report::{ReceiverReport, SenderReport};
31pub use sender::SenderState;
32
33// Session-layer re-exports
34// MmpSessionState and PathMtuState are defined in this file
35
36// ============================================================================
37// Constants
38// ============================================================================
39
40/// SenderReport body size (after msg_type byte): 3 reserved + 44 payload = 47.
41pub const SENDER_REPORT_BODY_SIZE: usize = 47;
42
43/// ReceiverReport body size (after msg_type byte): 3 reserved + 64 payload = 67.
44pub const RECEIVER_REPORT_BODY_SIZE: usize = 67;
45
46/// SenderReport total wire size including inner header: 5 + 47 = 52.
47pub const SENDER_REPORT_WIRE_SIZE: usize = 52;
48
49/// ReceiverReport total wire size including inner header: 5 + 67 = 72.
50pub const RECEIVER_REPORT_WIRE_SIZE: usize = 72;
51
52/// Smallest remotely supplied path MTU that can describe a usable FIPS path.
53pub const MIN_ACTIONABLE_PATH_MTU: u16 = 256;
54
55// --- EWMA parameters (as shift amounts for integer arithmetic) ---
56
57/// Jitter EWMA: α = 1/16 (RFC 3550 §6.4.1).
58pub const JITTER_ALPHA_SHIFT: u32 = 4;
59
60/// SRTT: α = 1/8 (Jacobson, RFC 6298).
61pub const SRTT_ALPHA_SHIFT: u32 = 3;
62
63/// RTTVAR: β = 1/4 (Jacobson, RFC 6298).
64pub const RTTVAR_BETA_SHIFT: u32 = 2;
65
66/// Dual EWMA short-term: α = 1/4.
67pub const EWMA_SHORT_ALPHA: f64 = 0.25;
68
69/// Dual EWMA long-term: α = 1/32.
70pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0;
71
72// --- Timing defaults (milliseconds) ---
73
74/// Default report interval before SRTT is available (cold start).
75pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200;
76
77/// Minimum report interval (SRTT clamp floor).
78///
79/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
80/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
81/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
82/// fast initial SRTT convergence before transitioning to this floor.
83pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
84
85/// Maximum report interval (SRTT clamp ceiling).
86pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000;
87
88/// Number of SRTT samples before transitioning from cold-start to normal floor.
89///
90/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as
91/// the floor to gather SRTT samples quickly. After this many updates, the floor
92/// switches to `MIN_REPORT_INTERVAL_MS`.
93pub const COLD_START_SAMPLES: u32 = 5;
94
95/// Default OWD ring buffer capacity.
96pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
97
98/// Default operator log interval in seconds.
99pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
100
101// --- Session-layer timing defaults ---
102// Session reports are routed end-to-end (bandwidth cost on every transit link),
103// so intervals are higher than link-layer.
104
105/// Session-layer minimum report interval.
106pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
107
108/// Session-layer maximum report interval.
109pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
110
111/// Session-layer cold-start report interval (before SRTT is available).
112pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
113
114// ============================================================================
115// Operating Mode
116// ============================================================================
117
118/// MMP operating mode.
119#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum MmpMode {
122    /// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity.
123    #[default]
124    Full,
125    /// Receiver reports only. Loss inferred from counter gaps.
126    Lightweight,
127    /// Spin bit + CE echo only. No reports exchanged.
128    Minimal,
129}
130
131impl fmt::Display for MmpMode {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            MmpMode::Full => write!(f, "full"),
135            MmpMode::Lightweight => write!(f, "lightweight"),
136            MmpMode::Minimal => write!(f, "minimal"),
137        }
138    }
139}
140
141// ============================================================================
142// Configuration
143// ============================================================================
144
145/// MMP configuration (`node.mmp.*`).
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct MmpConfig {
148    /// Operating mode (`node.mmp.mode`).
149    #[serde(default)]
150    pub mode: MmpMode,
151
152    /// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
153    #[serde(default = "MmpConfig::default_log_interval_secs")]
154    pub log_interval_secs: u64,
155
156    /// OWD trend ring buffer size (`node.mmp.owd_window_size`).
157    #[serde(default = "MmpConfig::default_owd_window_size")]
158    pub owd_window_size: usize,
159}
160
161impl Default for MmpConfig {
162    fn default() -> Self {
163        Self {
164            mode: MmpMode::default(),
165            log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
166            owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
167        }
168    }
169}
170
171impl MmpConfig {
172    fn default_log_interval_secs() -> u64 {
173        DEFAULT_LOG_INTERVAL_SECS
174    }
175    fn default_owd_window_size() -> usize {
176        DEFAULT_OWD_WINDOW_SIZE
177    }
178}
179
180// ============================================================================
181// Per-Peer MMP State
182// ============================================================================
183
184/// Combined MMP state for a single peer link.
185///
186/// Wraps sender, receiver, metrics, and spin bit state. One instance
187/// per `ActivePeer`.
188pub struct MmpPeerState {
189    pub sender: SenderState,
190    pub receiver: ReceiverState,
191    pub metrics: MmpMetrics,
192    pub spin_bit: SpinBitState,
193    mode: MmpMode,
194    log_interval: Duration,
195    last_log_time: Option<Instant>,
196}
197
198impl MmpPeerState {
199    /// Create MMP state for a new peer link.
200    ///
201    /// `is_initiator`: true if this node initiated the Noise handshake
202    /// (determines spin bit role).
203    pub fn new(config: &MmpConfig, is_initiator: bool) -> Self {
204        Self {
205            sender: SenderState::new(),
206            receiver: ReceiverState::new(config.owd_window_size),
207            metrics: MmpMetrics::new(),
208            spin_bit: SpinBitState::new(is_initiator),
209            mode: config.mode,
210            log_interval: Duration::from_secs(config.log_interval_secs),
211            last_log_time: None,
212        }
213    }
214
215    /// Reset counter-dependent state for rekey cutover.
216    pub fn reset_for_rekey(&mut self, now: Instant) {
217        self.receiver.reset_for_rekey(now);
218        self.metrics.reset_for_rekey();
219    }
220
221    /// Current operating mode.
222    pub fn mode(&self) -> MmpMode {
223        self.mode
224    }
225
226    /// Check if it's time to emit a periodic metrics log.
227    pub fn should_log(&self, now: Instant) -> bool {
228        match self.last_log_time {
229            None => true,
230            Some(last) => now.duration_since(last) >= self.log_interval,
231        }
232    }
233
234    /// Mark that a periodic log was emitted.
235    pub fn mark_logged(&mut self, now: Instant) {
236        self.last_log_time = Some(now);
237    }
238}
239
240// ============================================================================
241// Per-Session MMP State (session-layer instantiation)
242// ============================================================================
243
244/// Combined MMP state for a single end-to-end session.
245///
246/// Wraps sender, receiver, metrics, spin bit, and path MTU state.
247/// One instance per established `SessionEntry`.
248pub struct MmpSessionState {
249    pub sender: SenderState,
250    pub receiver: ReceiverState,
251    pub metrics: MmpMetrics,
252    pub spin_bit: SpinBitState,
253    mode: MmpMode,
254    log_interval: Duration,
255    last_log_time: Option<Instant>,
256    pub path_mtu: PathMtuState,
257}
258
259impl MmpSessionState {
260    /// Create MMP state for a new session.
261    ///
262    /// `is_initiator`: true if this node initiated the Noise handshake
263    /// (determines spin bit role).
264    pub fn new(config: &crate::config::SessionMmpConfig, is_initiator: bool) -> Self {
265        Self {
266            sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS),
267            receiver: ReceiverState::new_with_cold_start(
268                config.owd_window_size,
269                SESSION_COLD_START_INTERVAL_MS,
270            ),
271            metrics: MmpMetrics::new(),
272            spin_bit: SpinBitState::new(is_initiator),
273            mode: config.mode,
274            log_interval: Duration::from_secs(config.log_interval_secs),
275            last_log_time: None,
276            path_mtu: PathMtuState::new(),
277        }
278    }
279
280    /// Reset counter-dependent state for rekey cutover.
281    pub fn reset_for_rekey(&mut self, now: Instant) {
282        self.receiver.reset_for_rekey(now);
283        self.metrics.reset_for_rekey();
284    }
285
286    /// Current operating mode.
287    pub fn mode(&self) -> MmpMode {
288        self.mode
289    }
290
291    /// Check if it's time to emit a periodic metrics log.
292    pub fn should_log(&self, now: Instant) -> bool {
293        match self.last_log_time {
294            None => true,
295            Some(last) => now.duration_since(last) >= self.log_interval,
296        }
297    }
298
299    /// Mark that a periodic log was emitted.
300    pub fn mark_logged(&mut self, now: Instant) {
301        self.last_log_time = Some(now);
302    }
303}
304
305impl Debug for MmpSessionState {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        f.debug_struct("MmpSessionState")
308            .field("mode", &self.mode)
309            .field("path_mtu", &self.path_mtu.current_mtu())
310            .finish_non_exhaustive()
311    }
312}
313
314// ============================================================================
315// Path MTU State (session-layer only)
316// ============================================================================
317
318/// Path MTU tracking for a single session.
319///
320/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes
321/// and generates PathMtuNotification messages back to the source.
322///
323/// Source side: applies received PathMtuNotification to limit outbound datagram
324/// size. Decrease is immediate; increase requires 3 consecutive notifications.
325pub struct PathMtuState {
326    /// Current effective path MTU (what we use for sending).
327    current_mtu: u16,
328    /// Last observed path MTU from incoming datagrams (destination-side).
329    last_observed_mtu: u16,
330    /// Whether the observed MTU has changed since the last notification.
331    observed_changed: bool,
332    /// Last time a PathMtuNotification was sent.
333    last_notification_time: Option<Instant>,
334    /// Notification interval: max(10s, 5 * SRTT). Default 10s.
335    notification_interval: Duration,
336    /// For source-side increase tracking: consecutive higher-value notifications.
337    consecutive_increase_count: u8,
338    /// Time of the first notification in the current increase sequence.
339    first_increase_time: Option<Instant>,
340    /// The MTU value being proposed for increase.
341    pending_increase_mtu: u16,
342}
343
344impl PathMtuState {
345    /// Create path MTU state with no initial measurement.
346    pub fn new() -> Self {
347        Self {
348            current_mtu: u16::MAX,
349            last_observed_mtu: u16::MAX,
350            observed_changed: false,
351            last_notification_time: None,
352            notification_interval: Duration::from_secs(10),
353            consecutive_increase_count: 0,
354            first_increase_time: None,
355            pending_increase_mtu: 0,
356        }
357    }
358
359    /// Current effective path MTU (source-side, for sending).
360    pub fn current_mtu(&self) -> u16 {
361        self.current_mtu
362    }
363
364    /// Last observed incoming path MTU (destination-side).
365    pub fn last_observed_mtu(&self) -> u16 {
366        self.last_observed_mtu
367    }
368
369    /// Update notification interval from SRTT: max(10s, 5 * SRTT).
370    pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) {
371        let five_srtt = Duration::from_millis((srtt_ms * 5.0) as u64);
372        self.notification_interval = five_srtt.max(Duration::from_secs(10));
373    }
374
375    /// Seed source-side current_mtu from outbound transport MTU.
376    ///
377    /// Called on each send. Only decreases (never increases) the current_mtu
378    /// so the destination's PathMtuNotification can still raise it later.
379    /// Ensures current_mtu doesn't stay at u16::MAX before any notification
380    /// arrives from the destination.
381    pub fn seed_source_mtu(&mut self, outbound_mtu: u16) {
382        if outbound_mtu < self.current_mtu {
383            self.current_mtu = outbound_mtu;
384        }
385    }
386
387    // --- Destination side ---
388
389    /// Observe the path_mtu from an incoming SessionDatagram envelope.
390    ///
391    /// Called on the destination (receiver) side for every session message.
392    pub fn observe_incoming_mtu(&mut self, path_mtu: u16) {
393        if path_mtu != self.last_observed_mtu {
394            self.observed_changed = true;
395            self.last_observed_mtu = path_mtu;
396        }
397    }
398
399    /// Check if a PathMtuNotification should be sent.
400    ///
401    /// Send on first measurement, on decrease (immediate), or periodic
402    /// confirmation at the notification interval.
403    pub fn should_send_notification(&self, now: Instant) -> bool {
404        if self.last_observed_mtu == u16::MAX {
405            return false; // No measurement yet
406        }
407        match self.last_notification_time {
408            None => true, // First measurement
409            Some(last) => {
410                // Immediate on decrease
411                if self.observed_changed && self.last_observed_mtu < self.current_mtu {
412                    return true;
413                }
414                // Periodic confirmation
415                now.duration_since(last) >= self.notification_interval
416            }
417        }
418    }
419
420    /// Build a PathMtuNotification from current state.
421    ///
422    /// Returns the path_mtu value to send. Caller handles encoding.
423    pub fn build_notification(&mut self, now: Instant) -> Option<u16> {
424        if self.last_observed_mtu == u16::MAX {
425            return None;
426        }
427        self.last_notification_time = Some(now);
428        self.observed_changed = false;
429        Some(self.last_observed_mtu)
430    }
431
432    // --- Source side ---
433
434    /// Apply a received PathMtuNotification.
435    ///
436    /// - Decrease: immediate (take the lower value).
437    /// - Increase: require 3 consecutive notifications with the same higher
438    ///   value, spanning at least 2 * notification_interval.
439    ///
440    /// Returns `true` if the effective MTU changed.
441    pub fn apply_notification(&mut self, reported_mtu: u16, now: Instant) -> bool {
442        if reported_mtu < MIN_ACTIONABLE_PATH_MTU {
443            return false;
444        }
445        if reported_mtu < self.current_mtu {
446            // Decrease: immediate
447            self.current_mtu = reported_mtu;
448            self.consecutive_increase_count = 0;
449            self.first_increase_time = None;
450            return true;
451        }
452
453        if reported_mtu > self.current_mtu {
454            // Increase: track consecutive notifications
455            if reported_mtu == self.pending_increase_mtu {
456                self.consecutive_increase_count += 1;
457            } else {
458                // Different value: reset sequence
459                self.pending_increase_mtu = reported_mtu;
460                self.consecutive_increase_count = 1;
461                self.first_increase_time = Some(now);
462            }
463
464            // Accept increase after 3 consecutive spanning 2 * interval
465            if self.consecutive_increase_count >= 3
466                && let Some(first_time) = self.first_increase_time
467            {
468                let required = self.notification_interval * 2;
469                if now.duration_since(first_time) >= required {
470                    self.current_mtu = reported_mtu;
471                    self.consecutive_increase_count = 0;
472                    self.first_increase_time = None;
473                    return true;
474                }
475            }
476        }
477
478        // No change (equal or increase not yet confirmed)
479        false
480    }
481}
482
483impl Default for PathMtuState {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl Debug for MmpPeerState {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        f.debug_struct("MmpPeerState")
492            .field("mode", &self.mode)
493            .finish_non_exhaustive()
494    }
495}
496
497// ============================================================================
498// Tests
499// ============================================================================
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_mode_default() {
507        assert_eq!(MmpMode::default(), MmpMode::Full);
508    }
509
510    #[test]
511    fn test_mode_display() {
512        assert_eq!(MmpMode::Full.to_string(), "full");
513        assert_eq!(MmpMode::Lightweight.to_string(), "lightweight");
514        assert_eq!(MmpMode::Minimal.to_string(), "minimal");
515    }
516
517    #[test]
518    fn test_mode_serde_roundtrip() {
519        let yaml = "full";
520        let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
521        assert_eq!(mode, MmpMode::Full);
522
523        let yaml = "lightweight";
524        let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
525        assert_eq!(mode, MmpMode::Lightweight);
526
527        let yaml = "minimal";
528        let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
529        assert_eq!(mode, MmpMode::Minimal);
530    }
531
532    #[test]
533    fn test_config_default() {
534        let config = MmpConfig::default();
535        assert_eq!(config.mode, MmpMode::Full);
536        assert_eq!(config.log_interval_secs, 30);
537        assert_eq!(config.owd_window_size, 32);
538    }
539
540    #[test]
541    fn test_config_yaml_parse() {
542        let yaml = r#"
543mode: lightweight
544log_interval_secs: 60
545owd_window_size: 48
546"#;
547        let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
548        assert_eq!(config.mode, MmpMode::Lightweight);
549        assert_eq!(config.log_interval_secs, 60);
550        assert_eq!(config.owd_window_size, 48);
551    }
552
553    #[test]
554    fn test_config_yaml_partial() {
555        let yaml = "mode: minimal";
556        let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
557        assert_eq!(config.mode, MmpMode::Minimal);
558        assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
559        assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
560    }
561}