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 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
99const PROBE_TARGET: &str = "1.1.1.1";
107const 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 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 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 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 let mut lo = PAYLOAD_LO; let mut hi = PAYLOAD_HI; 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
181fn 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
189fn 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
224async 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 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}