Skip to main content

vcl_protocol/
mtu.rs

1//! # VCL MTU Negotiation
2//!
3//! Automatic MTU (Maximum Transmission Unit) discovery and negotiation
4//! for VCL connections.
5//!
6//! ## Why MTU matters for VPN
7//!
8//! ```text
9//! Physical MTU:  1500 bytes (Ethernet)
10//! IP header:       20 bytes
11//! UDP header:       8 bytes
12//! VCL header:      ~64 bytes
13//! ─────────────────────────
14//! Usable payload: 1408 bytes  ← this is what fragment_size should be
15//! ```
16//!
17//! If packets are larger than the path MTU they get fragmented by the OS
18//! or silently dropped — causing mysterious slowdowns in VPN tunnels.
19//!
20//! ## Example
21//!
22//! ```rust
23//! use vcl_protocol::mtu::{MtuConfig, MtuNegotiator, PathMtu};
24//!
25//! let config = MtuConfig::default();
26//! let mut negotiator = MtuNegotiator::new(config);
27//!
28//! // Probe results come in as you send test packets
29//! negotiator.record_probe(1400, true);
30//! negotiator.record_probe(1450, false); // dropped — too large
31//!
32//! let mtu = negotiator.current_mtu();
33//! println!("Path MTU: {}", mtu);
34//!
35//! let vcl_payload = negotiator.recommended_fragment_size();
36//! println!("Use fragment_size: {}", vcl_payload);
37//! ```
38
39use std::time::{Duration, Instant};
40use tracing::{debug, info, warn};
41
42/// Overhead added by VCL Protocol headers on top of IP+UDP.
43/// Ed25519 sig(64) + prev_hash(32) + nonce(24) + sequence(8) + version(1) + bincode overhead(~20)
44pub const VCL_HEADER_OVERHEAD: usize = 149;
45
46/// Standard Ethernet MTU.
47pub const ETHERNET_MTU: usize = 1500;
48
49/// IPv4 header size (minimum, no options).
50pub const IPV4_HEADER: usize = 20;
51
52/// IPv6 header size (fixed).
53pub const IPV6_HEADER: usize = 40;
54
55/// UDP header size.
56pub const UDP_HEADER: usize = 8;
57
58/// Minimum safe MTU — guaranteed to work everywhere (RFC 791).
59pub const MIN_MTU: usize = 576;
60
61/// Maximum MTU we will ever probe.
62pub const MAX_MTU: usize = 9000; // jumbo frames
63
64/// Configuration for MTU negotiation.
65#[derive(Debug, Clone)]
66pub struct MtuConfig {
67    /// Starting MTU to probe from (default: 1500).
68    pub start_mtu: usize,
69    /// Minimum acceptable MTU (default: 576).
70    pub min_mtu: usize,
71    /// Maximum MTU to probe (default: 1500).
72    pub max_mtu: usize,
73    /// Step size for binary search (default: 8 bytes).
74    pub step: usize,
75    /// How long to wait for a probe response before declaring loss.
76    pub probe_timeout: Duration,
77    /// Use IPv6 (adds 40 bytes header instead of 20).
78    pub ipv6: bool,
79    /// Extra overhead from other encapsulation layers (e.g. WireGuard inside VCL).
80    pub extra_overhead: usize,
81}
82
83impl Default for MtuConfig {
84    fn default() -> Self {
85        MtuConfig {
86            start_mtu: ETHERNET_MTU,
87            min_mtu: MIN_MTU,
88            max_mtu: ETHERNET_MTU,
89            step: 8,
90            probe_timeout: Duration::from_secs(2),
91            ipv6: false,
92            extra_overhead: 0,
93        }
94    }
95}
96
97impl MtuConfig {
98    /// Config for a connection running over IPv4/UDP.
99    pub fn ipv4_udp() -> Self {
100        MtuConfig::default()
101    }
102
103    /// Config for a connection running over IPv6/UDP.
104    pub fn ipv6_udp() -> Self {
105        MtuConfig {
106            ipv6: true,
107            ..Default::default()
108        }
109    }
110
111    /// Config for a connection inside a WireGuard tunnel (adds 60 bytes overhead).
112    pub fn inside_wireguard() -> Self {
113        MtuConfig {
114            max_mtu: 1420,
115            start_mtu: 1420,
116            extra_overhead: 60,
117            ..Default::default()
118        }
119    }
120}
121
122/// The result of MTU discovery for a network path.
123#[derive(Debug, Clone)]
124pub struct PathMtu {
125    /// The discovered path MTU in bytes.
126    pub mtu: usize,
127    /// Recommended VCL fragment_size for this path.
128    pub fragment_size: usize,
129    /// When this MTU was last confirmed.
130    pub measured_at: Instant,
131    /// Whether this value was actively probed or is a safe default.
132    pub is_probed: bool,
133}
134
135impl PathMtu {
136    /// Create a new PathMtu.
137    pub fn new(mtu: usize, fragment_size: usize, is_probed: bool) -> Self {
138        PathMtu {
139            mtu,
140            fragment_size,
141            measured_at: Instant::now(),
142            is_probed,
143        }
144    }
145
146    /// Returns `true` if this measurement is older than `max_age`.
147    pub fn is_stale(&self, max_age: Duration) -> bool {
148        self.measured_at.elapsed() > max_age
149    }
150}
151
152/// State of the MTU negotiation process.
153#[derive(Debug, Clone, PartialEq)]
154pub enum MtuState {
155    /// Initial state — no probing done yet.
156    Initial,
157    /// Binary search in progress.
158    Probing {
159        low: usize,
160        high: usize,
161        current: usize,
162    },
163    /// MTU confirmed — discovery complete.
164    Confirmed(usize),
165    /// Fell back to minimum safe MTU after all probes failed.
166    FallbackToMin,
167}
168
169/// Manages MTU discovery via binary search probing.
170///
171/// The caller is responsible for actually sending probe packets —
172/// this struct tracks state and interprets results.
173pub struct MtuNegotiator {
174    config: MtuConfig,
175    state: MtuState,
176    /// Current best confirmed MTU.
177    current_mtu: usize,
178    /// Pending probe: (probe_size, sent_at).
179    pending_probe: Option<(usize, Instant)>,
180    /// History of probe results: (size, success).
181    probe_history: Vec<(usize, bool)>,
182    /// Total probes sent.
183    total_probes: u64,
184    /// Total successful probes.
185    successful_probes: u64,
186}
187
188impl MtuNegotiator {
189    /// Create a new negotiator with the given config.
190    pub fn new(config: MtuConfig) -> Self {
191        let start = config.start_mtu;
192        let min = config.min_mtu;
193        let max = config.max_mtu;
194        MtuNegotiator {
195            state: MtuState::Initial,
196            current_mtu: start.min(max),
197            pending_probe: None,
198            probe_history: Vec::new(),
199            total_probes: 0,
200            successful_probes: 0,
201            config: MtuConfig { start_mtu: start, min_mtu: min, max_mtu: max, ..config },
202        }
203    }
204
205    /// Start MTU discovery. Returns the size of the first probe packet to send.
206    ///
207    /// The caller should send a packet of exactly this size and then call
208    /// `record_probe()` with the result.
209    pub fn start_discovery(&mut self) -> usize {
210        let low = self.config.min_mtu;
211        let high = self.config.max_mtu;
212        let current = (low + high) / 2;
213        self.state = MtuState::Probing { low, high, current };
214        self.pending_probe = Some((current, Instant::now()));
215        self.total_probes += 1;
216        info!(low, high, probe_size = current, "MTU discovery started");
217        current
218    }
219
220    /// Record the result of a probe.
221    ///
222    /// `size` — the probe packet size that was sent.
223    /// `success` — `true` if the probe was acknowledged, `false` if it was dropped/timed out.
224    ///
225    /// Returns the next probe size to send, or `None` if discovery is complete.
226    pub fn record_probe(&mut self, size: usize, success: bool) -> Option<usize> {
227        self.probe_history.push((size, success));
228        self.pending_probe = None;
229
230        if success {
231            self.successful_probes += 1;
232            debug!(size, "MTU probe succeeded");
233        } else {
234            warn!(size, "MTU probe failed (packet dropped)");
235        }
236
237        match self.state.clone() {
238            MtuState::Probing { low, high, current } => {
239                let (new_low, new_high) = if success {
240                    self.current_mtu = current;
241                    (current, high)
242                } else {
243                    (low, current - self.config.step)
244                };
245
246                // Check convergence
247                if new_high <= new_low || new_high - new_low <= self.config.step {
248                    // Discovery complete
249                    let final_mtu = if success { current } else { self.current_mtu };
250                    let final_mtu = final_mtu.max(self.config.min_mtu);
251                    self.current_mtu = final_mtu;
252                    self.state = MtuState::Confirmed(final_mtu);
253                    info!(mtu = final_mtu, "MTU discovery complete");
254                    return None;
255                }
256
257                let next = (new_low + new_high) / 2;
258                self.state = MtuState::Probing {
259                    low: new_low,
260                    high: new_high,
261                    current: next,
262                };
263                self.pending_probe = Some((next, Instant::now()));
264                self.total_probes += 1;
265                debug!(next_probe = next, low = new_low, high = new_high, "Next MTU probe");
266                Some(next)
267            }
268            _ => {
269                // Record probe even in non-probing states
270                if success && size > self.current_mtu {
271                    self.current_mtu = size;
272                }
273                None
274            }
275        }
276    }
277
278    /// Check if a pending probe has timed out.
279    ///
280    /// If it has, call `record_probe(size, false)` to register the failure.
281    /// Returns the timed-out probe size if applicable.
282    pub fn check_probe_timeout(&self) -> Option<usize> {
283        if let Some((size, sent_at)) = self.pending_probe {
284            if sent_at.elapsed() > self.config.probe_timeout {
285                return Some(size);
286            }
287        }
288        None
289    }
290
291    /// Returns the current best known MTU.
292    pub fn current_mtu(&self) -> usize {
293        self.current_mtu
294    }
295
296    /// Returns the current negotiation state.
297    pub fn state(&self) -> &MtuState {
298        &self.state
299    }
300
301    /// Returns `true` if MTU discovery is complete.
302    pub fn is_complete(&self) -> bool {
303        matches!(self.state, MtuState::Confirmed(_) | MtuState::FallbackToMin)
304    }
305
306    /// Returns the recommended `fragment_size` for [`VCLConfig`] based on
307    /// the current MTU, subtracting all protocol headers.
308    ///
309    /// [`VCLConfig`]: crate::config::VCLConfig
310    pub fn recommended_fragment_size(&self) -> usize {
311        let ip_header = if self.config.ipv6 { IPV6_HEADER } else { IPV4_HEADER };
312        let overhead = ip_header
313            + UDP_HEADER
314            + VCL_HEADER_OVERHEAD
315            + self.config.extra_overhead;
316
317        if self.current_mtu <= overhead {
318            warn!(
319                mtu = self.current_mtu,
320                overhead,
321                "MTU smaller than overhead — using minimum fragment size"
322            );
323            return 64; // absolute minimum
324        }
325
326        let fragment_size = self.current_mtu - overhead;
327        // Align down to nearest 8 bytes for efficiency
328        (fragment_size / 8) * 8
329    }
330
331    /// Force-set the MTU without probing (e.g. from OS PMTUD or known config).
332    pub fn set_mtu(&mut self, mtu: usize) {
333        let clamped = mtu.clamp(self.config.min_mtu, MAX_MTU);
334        info!(mtu = clamped, "MTU manually set");
335        self.current_mtu = clamped;
336        self.state = MtuState::Confirmed(clamped);
337    }
338
339    /// Fall back to the minimum safe MTU.
340    pub fn fallback_to_min(&mut self) {
341        warn!(min = self.config.min_mtu, "MTU falling back to minimum");
342        self.current_mtu = self.config.min_mtu;
343        self.state = MtuState::FallbackToMin;
344    }
345
346    /// Returns a [`PathMtu`] snapshot of the current state.
347    pub fn path_mtu(&self) -> PathMtu {
348        PathMtu::new(
349            self.current_mtu,
350            self.recommended_fragment_size(),
351            self.successful_probes > 0,
352        )
353    }
354
355    /// Returns total probes sent.
356    pub fn total_probes(&self) -> u64 {
357        self.total_probes
358    }
359
360    /// Returns total successful probes.
361    pub fn successful_probes(&self) -> u64 {
362        self.successful_probes
363    }
364
365    /// Returns the full probe history as (size, success) pairs.
366    pub fn probe_history(&self) -> &[(usize, bool)] {
367        &self.probe_history
368    }
369}
370
371/// Compute the recommended fragment size for a known MTU and transport.
372///
373/// Convenience function for when you already know the MTU.
374///
375/// ```rust
376/// use vcl_protocol::mtu::fragment_size_for_mtu;
377///
378/// let fs = fragment_size_for_mtu(1500, false, 0);
379/// assert!(fs > 0 && fs < 1500);
380/// ```
381pub fn fragment_size_for_mtu(mtu: usize, ipv6: bool, extra_overhead: usize) -> usize {
382    let ip_header = if ipv6 { IPV6_HEADER } else { IPV4_HEADER };
383    let overhead = ip_header + UDP_HEADER + VCL_HEADER_OVERHEAD + extra_overhead;
384    if mtu <= overhead {
385        return 64;
386    }
387    ((mtu - overhead) / 8) * 8
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_default_config() {
396        let c = MtuConfig::default();
397        assert_eq!(c.start_mtu, 1500);
398        assert_eq!(c.min_mtu, 576);
399        assert!(!c.ipv6);
400    }
401
402    #[test]
403    fn test_ipv6_config() {
404        let c = MtuConfig::ipv6_udp();
405        assert!(c.ipv6);
406    }
407
408    #[test]
409    fn test_wireguard_config() {
410        let c = MtuConfig::inside_wireguard();
411        assert_eq!(c.max_mtu, 1420);
412        assert_eq!(c.extra_overhead, 60);
413    }
414
415    #[test]
416    fn test_negotiator_new() {
417        let n = MtuNegotiator::new(MtuConfig::default());
418        assert_eq!(n.state(), &MtuState::Initial);
419        assert_eq!(n.current_mtu(), 1500);
420        assert!(!n.is_complete());
421    }
422
423    #[test]
424    fn test_start_discovery() {
425        let mut n = MtuNegotiator::new(MtuConfig::default());
426        let probe = n.start_discovery();
427        assert!(probe > 576 && probe < 1500);
428        assert!(matches!(n.state(), MtuState::Probing { .. }));
429        assert_eq!(n.total_probes(), 1);
430    }
431
432    #[test]
433    fn test_record_probe_success() {
434        let mut n = MtuNegotiator::new(MtuConfig::default());
435        n.start_discovery();
436        let next = n.record_probe(1038, true);
437        // Should continue probing or complete
438        assert!(n.current_mtu() >= 1038);
439    }
440
441    #[test]
442    fn test_record_probe_failure() {
443        let mut n = MtuNegotiator::new(MtuConfig::default());
444        n.start_discovery();
445        let _ = n.record_probe(1038, false);
446        // MTU should stay at start value since probe failed
447        assert!(n.current_mtu() <= 1500);
448    }
449
450    #[test]
451    fn test_full_discovery_converges() {
452        let mut n = MtuNegotiator::new(MtuConfig::default());
453        let mut probe = n.start_discovery();
454
455        // Simulate: anything <= 1400 succeeds, > 1400 fails
456        for _ in 0..20 {
457            let success = probe <= 1400;
458            match n.record_probe(probe, success) {
459                Some(next) => probe = next,
460                None => break,
461            }
462        }
463
464        assert!(n.is_complete());
465        assert!(n.current_mtu() <= 1400);
466        assert!(n.current_mtu() >= 576);
467    }
468
469    #[test]
470    fn test_recommended_fragment_size() {
471        let mut n = MtuNegotiator::new(MtuConfig::default());
472        n.set_mtu(1500);
473        let fs = n.recommended_fragment_size();
474        assert!(fs > 0);
475        assert!(fs < 1500);
476        // Should be aligned to 8
477        assert_eq!(fs % 8, 0);
478    }
479
480    #[test]
481    fn test_fragment_size_for_mtu_fn() {
482        let fs = fragment_size_for_mtu(1500, false, 0);
483        assert!(fs > 0 && fs < 1500);
484        assert_eq!(fs % 8, 0);
485
486        let fs_v6 = fragment_size_for_mtu(1500, true, 0);
487        assert!(fs_v6 < fs); // IPv6 header is larger
488    }
489
490    #[test]
491    fn test_set_mtu() {
492        let mut n = MtuNegotiator::new(MtuConfig::default());
493        n.set_mtu(1280);
494        assert_eq!(n.current_mtu(), 1280);
495        assert!(n.is_complete());
496        assert!(matches!(n.state(), MtuState::Confirmed(1280)));
497    }
498
499    #[test]
500    fn test_set_mtu_clamped() {
501        let mut n = MtuNegotiator::new(MtuConfig::default());
502        n.set_mtu(100); // below min (576)
503        assert_eq!(n.current_mtu(), 576);
504    }
505
506    #[test]
507    fn test_fallback_to_min() {
508        let mut n = MtuNegotiator::new(MtuConfig::default());
509        n.fallback_to_min();
510        assert_eq!(n.current_mtu(), 576);
511        assert_eq!(n.state(), &MtuState::FallbackToMin);
512        assert!(n.is_complete());
513    }
514
515    #[test]
516    fn test_path_mtu() {
517        let mut n = MtuNegotiator::new(MtuConfig::default());
518        n.set_mtu(1400);
519        let pm = n.path_mtu();
520        assert_eq!(pm.mtu, 1400);
521        assert!(pm.fragment_size < 1400);
522        assert!(!pm.is_probed); // no probes done, just set_mtu
523    }
524
525    #[test]
526    fn test_probe_history() {
527        let mut n = MtuNegotiator::new(MtuConfig::default());
528        n.start_discovery();
529        n.record_probe(1038, true);
530        assert_eq!(n.probe_history().len(), 1);
531        assert_eq!(n.probe_history()[0], (1038, true));
532    }
533
534    #[test]
535    fn test_check_probe_timeout_no_pending() {
536        let n = MtuNegotiator::new(MtuConfig::default());
537        assert!(n.check_probe_timeout().is_none());
538    }
539
540    #[test]
541    fn test_check_probe_timeout_not_yet() {
542        let mut n = MtuNegotiator::new(MtuConfig::default());
543        n.start_discovery();
544        // Probe just sent, shouldn't timeout yet
545        assert!(n.check_probe_timeout().is_none());
546    }
547
548    #[test]
549    fn test_mtu_smaller_than_overhead() {
550        let config = MtuConfig {
551            start_mtu: 100,
552            min_mtu: 64,
553            max_mtu: 100,
554            ..Default::default()
555        };
556        let mut n = MtuNegotiator::new(config);
557        n.set_mtu(100);
558        // overhead > 100, should return 64 minimum
559        assert_eq!(n.recommended_fragment_size(), 64);
560    }
561
562    #[test]
563    fn test_extra_overhead() {
564        let fs1 = fragment_size_for_mtu(1500, false, 0);
565        let fs2 = fragment_size_for_mtu(1500, false, 60); // WireGuard overhead
566        assert!(fs2 < fs1);
567    }
568
569    #[test]
570    fn test_total_probes_counted() {
571        let mut n = MtuNegotiator::new(MtuConfig::default());
572        n.start_discovery();
573        assert_eq!(n.total_probes(), 1);
574        n.record_probe(1038, true);
575        assert!(n.total_probes() >= 1);
576    }
577}