Skip to main content

nd_300/diagnostics/
mtu.rs

1use serde::Serialize;
2
3#[derive(Debug, Clone, Serialize)]
4pub struct MtuInfo {
5    pub interface: String,
6    pub mtu: u32,
7}
8
9pub async fn collect() -> Option<Vec<MtuInfo>> {
10    let mut results = Vec::new();
11
12    #[cfg(windows)]
13    {
14        let mut cmd = tokio::process::Command::new("netsh");
15        cmd.args(["interface", "ipv4", "show", "subinterfaces"]);
16        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
17            let text = String::from_utf8_lossy(&output.stdout);
18            for line in text.lines() {
19                let parts: Vec<&str> = line.split_whitespace().collect();
20                // Format: MTU  MediaSenseState   Bytes In  Bytes Out  Interface
21                if parts.len() >= 5 {
22                    if let Ok(mtu) = parts[0].parse::<u32>() {
23                        let iface = parts[4..].join(" ");
24                        results.push(MtuInfo {
25                            interface: iface,
26                            mtu,
27                        });
28                    }
29                }
30            }
31        }
32    }
33
34    #[cfg(target_os = "linux")]
35    {
36        let mut cmd = tokio::process::Command::new("ip");
37        cmd.args(["link", "show"]);
38        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
39            let text = String::from_utf8_lossy(&output.stdout);
40            for line in text.lines() {
41                if line.contains("mtu") {
42                    let parts: Vec<&str> = line.split_whitespace().collect();
43                    if let Some(name_idx) = parts
44                        .iter()
45                        .position(|p| p.ends_with(':') && !p.starts_with(' '))
46                    {
47                        let name = parts[name_idx].trim_end_matches(':');
48                        if let Some(mtu_idx) = parts.iter().position(|p| *p == "mtu") {
49                            if let Some(mtu_val) = parts.get(mtu_idx + 1) {
50                                if let Ok(mtu) = mtu_val.parse::<u32>() {
51                                    results.push(MtuInfo {
52                                        interface: name.to_string(),
53                                        mtu,
54                                    });
55                                }
56                            }
57                        }
58                    }
59                }
60            }
61        }
62    }
63
64    #[cfg(target_os = "macos")]
65    {
66        let cmd = tokio::process::Command::new("ifconfig");
67        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
68            let text = String::from_utf8_lossy(&output.stdout);
69            let mut current_iface = String::new();
70
71            for line in text.lines() {
72                if !line.starts_with('\t') && !line.starts_with(' ') {
73                    current_iface = line.split(':').next().unwrap_or("").to_string();
74                }
75                if line.contains("mtu") {
76                    let parts: Vec<&str> = line.split_whitespace().collect();
77                    if let Some(mtu_idx) = parts.iter().position(|p| *p == "mtu") {
78                        if let Some(mtu_val) = parts.get(mtu_idx + 1) {
79                            if let Ok(mtu) = mtu_val.parse::<u32>() {
80                                results.push(MtuInfo {
81                                    interface: current_iface.clone(),
82                                    mtu,
83                                });
84                            }
85                        }
86                    }
87                }
88            }
89        }
90    }
91
92    if results.is_empty() {
93        None
94    } else {
95        Some(results)
96    }
97}
98
99// ── Path MTU discovery (v3.4.0+) ────────────────────────────────────────────
100//
101// The listing above only reads the INTERFACE MTU. This probe measures the
102// PATH MTU with a don't-fragment binary search against 1.1.1.1 — a clamped
103// path (VPN/tunnel overhead) or broken PMTUD causes mysterious stalls on
104// large transfers that interface MTUs can't explain.
105
106const PROBE_TARGET: &str = "1.1.1.1";
107/// ICMP payload search bounds; payload + 28 (IP+ICMP headers) = MTU, so this
108/// covers MTUs 1228–1500.
109const PAYLOAD_LO: u32 = 1200;
110const PAYLOAD_HI: u32 = 1472;
111const MAX_PROBES: u32 = 8;
112
113#[derive(Debug, Clone, Serialize)]
114pub struct PathMtu {
115    pub target: String,
116    pub path_mtu: u32,
117    /// True when the path MTU is below the standard Ethernet 1500.
118    pub clamped: bool,
119    pub probes: u32,
120    pub assessment: String,
121    pub level: String,
122}
123
124pub async fn probe_path_mtu() -> Option<PathMtu> {
125    // Fast path: a full 1500-byte frame passing with DF means no clamp.
126    let mut probes = 1;
127    if probe_df(PAYLOAD_HI).await? {
128        let (assessment, level) = classify_path_mtu(payload_to_mtu(PAYLOAD_HI));
129        return Some(PathMtu {
130            target: PROBE_TARGET.to_string(),
131            path_mtu: payload_to_mtu(PAYLOAD_HI),
132            clamped: false,
133            probes,
134            assessment,
135            level,
136        });
137    }
138
139    // The floor failing too usually means DF probes are filtered outright
140    // (or PMTUD is broken) — report that rather than a fake number.
141    probes += 1;
142    if !probe_df(PAYLOAD_LO).await? {
143        return Some(PathMtu {
144            target: PROBE_TARGET.to_string(),
145            path_mtu: 0,
146            clamped: true,
147            probes,
148            assessment: format!(
149                "Even {}-byte don't-fragment probes fail — path MTU discovery appears broken (ICMP filtered?)",
150                payload_to_mtu(PAYLOAD_LO)
151            ),
152            level: "fail".to_string(),
153        });
154    }
155
156    // Binary search the largest passing payload in (lo_pass, hi_fail).
157    let mut lo = PAYLOAD_LO; // known pass
158    let mut hi = PAYLOAD_HI; // known fail
159    while probes < MAX_PROBES {
160        let Some(mid) = next_probe(lo, hi) else { break };
161        probes += 1;
162        if probe_df(mid).await? {
163            lo = mid;
164        } else {
165            hi = mid;
166        }
167    }
168
169    let path_mtu = payload_to_mtu(lo);
170    let (assessment, level) = classify_path_mtu(path_mtu);
171    Some(PathMtu {
172        target: PROBE_TARGET.to_string(),
173        path_mtu,
174        clamped: path_mtu < 1500,
175        probes,
176        assessment,
177        level,
178    })
179}
180
181/// Midpoint for the binary search; `None` when the bounds have converged.
182fn next_probe(lo_pass: u32, hi_fail: u32) -> Option<u32> {
183    if hi_fail <= lo_pass + 1 {
184        return None;
185    }
186    Some(lo_pass + (hi_fail - lo_pass) / 2)
187}
188
189/// ICMP payload bytes → MTU (20 IP + 8 ICMP header bytes).
190fn payload_to_mtu(payload: u32) -> u32 {
191    payload + 28
192}
193
194fn classify_path_mtu(mtu: u32) -> (String, String) {
195    if mtu >= 1500 {
196        (
197            "Full 1500-byte path — no clamping".to_string(),
198            "ok".to_string(),
199        )
200    } else if mtu >= 1492 {
201        (
202            "1492-byte path — standard PPPoE overhead, normal for DSL".to_string(),
203            "ok".to_string(),
204        )
205    } else if mtu >= 1400 {
206        (
207            format!(
208                "Path clamped to {} — typical of a tunnel or VPN in the path",
209                mtu
210            ),
211            "ok".to_string(),
212        )
213    } else {
214        (
215            format!(
216                "Path clamped to {} — heavy tunnel overhead; large packets risk fragmentation stalls",
217                mtu
218            ),
219            "warn".to_string(),
220        )
221    }
222}
223
224/// One don't-fragment ping with the given payload size. `Some(true)` = reply
225/// received; `Some(false)` = DF rejection/no reply; `None` = ping itself
226/// unavailable (probe aborts and the section is omitted).
227async fn probe_df(payload: u32) -> Option<bool> {
228    #[cfg(windows)]
229    let args: Vec<String> = vec![
230        "-f".into(),
231        "-l".into(),
232        payload.to_string(),
233        "-n".into(),
234        "1".into(),
235        "-w".into(),
236        "1500".into(),
237        PROBE_TARGET.into(),
238    ];
239    #[cfg(target_os = "linux")]
240    let args: Vec<String> = vec![
241        "-M".into(),
242        "do".into(),
243        "-s".into(),
244        payload.to_string(),
245        "-c".into(),
246        "1".into(),
247        "-W".into(),
248        "2".into(),
249        PROBE_TARGET.into(),
250    ];
251    #[cfg(target_os = "macos")]
252    let args: Vec<String> = vec![
253        "-D".into(),
254        "-s".into(),
255        payload.to_string(),
256        "-c".into(),
257        "1".into(),
258        PROBE_TARGET.into(),
259    ];
260
261    let mut cmd = tokio::process::Command::new("ping");
262    cmd.args(&args);
263    let output = super::util::run_with_timeout(cmd, super::util::SLOW).await?;
264    Some(output.status.success())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn payload_mtu_arithmetic() {
273        assert_eq!(payload_to_mtu(1472), 1500);
274        assert_eq!(payload_to_mtu(1464), 1492);
275        assert_eq!(payload_to_mtu(1200), 1228);
276    }
277
278    #[test]
279    fn binary_search_converges() {
280        // Simulate a 1400-byte path MTU (payload 1372): probe passes ≤1372.
281        let true_payload_limit = 1372;
282        let mut lo = PAYLOAD_LO;
283        let mut hi = PAYLOAD_HI;
284        let mut steps = 0;
285        while let Some(mid) = next_probe(lo, hi) {
286            steps += 1;
287            if mid <= true_payload_limit {
288                lo = mid;
289            } else {
290                hi = mid;
291            }
292            assert!(steps < 12, "search must converge");
293        }
294        assert_eq!(payload_to_mtu(lo), 1400);
295        assert!(steps <= 9);
296    }
297
298    #[test]
299    fn classify_thresholds() {
300        assert_eq!(classify_path_mtu(1500).1, "ok");
301        assert_eq!(classify_path_mtu(1492).1, "ok");
302        assert_eq!(classify_path_mtu(1420).1, "ok");
303        assert_eq!(classify_path_mtu(1340).1, "warn");
304    }
305}