Skip to main content

nd_300/diagnostics/
protocol_stats.rs

1use serde::Serialize;
2
3#[derive(Debug, Clone, Serialize)]
4pub struct ProtocolStatistics {
5    pub tcp: TcpStats,
6    pub udp: UdpStats,
7    pub icmp: IcmpStats,
8}
9
10#[derive(Debug, Clone, Serialize)]
11pub struct TcpStats {
12    pub active_opens: u64,
13    pub passive_opens: u64,
14    pub failed_connections: u64,
15    pub reset_connections: u64,
16    pub current_connections: u64,
17    pub segments_received: u64,
18    pub segments_sent: u64,
19    pub segments_retransmitted: u64,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct UdpStats {
24    pub datagrams_received: u64,
25    pub datagrams_sent: u64,
26    pub receive_errors: u64,
27    pub no_port_errors: u64,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct IcmpStats {
32    pub messages_received: u64,
33    pub messages_sent: u64,
34    pub errors_received: u64,
35    pub errors_sent: u64,
36}
37
38pub async fn collect() -> Option<ProtocolStatistics> {
39    #[cfg(windows)]
40    {
41        collect_windows().await
42    }
43
44    #[cfg(target_os = "macos")]
45    {
46        collect_macos().await
47    }
48
49    #[cfg(target_os = "linux")]
50    {
51        collect_linux().await
52    }
53}
54
55#[cfg(windows)]
56async fn collect_windows() -> Option<ProtocolStatistics> {
57    let mut cmd = tokio::process::Command::new("netstat");
58    cmd.args(["-s"]);
59    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
60
61    let text = String::from_utf8_lossy(&output.stdout);
62    let mut tcp = TcpStats {
63        active_opens: 0,
64        passive_opens: 0,
65        failed_connections: 0,
66        reset_connections: 0,
67        current_connections: 0,
68        segments_received: 0,
69        segments_sent: 0,
70        segments_retransmitted: 0,
71    };
72    let mut udp = UdpStats {
73        datagrams_received: 0,
74        datagrams_sent: 0,
75        receive_errors: 0,
76        no_port_errors: 0,
77    };
78    let mut icmp = IcmpStats {
79        messages_received: 0,
80        messages_sent: 0,
81        errors_received: 0,
82        errors_sent: 0,
83    };
84
85    let mut section = "";
86    for line in text.lines() {
87        let line = line.trim();
88        if line.contains("TCP Statistics") {
89            section = "tcp";
90            continue;
91        }
92        if line.contains("UDP Statistics") {
93            section = "udp";
94            continue;
95        }
96        if line.contains("ICMPv4 Statistics") || line.contains("ICMP Statistics") {
97            section = "icmp";
98            continue;
99        }
100        if line.contains("IPv4 Statistics") || line.contains("IPv6 Statistics") {
101            section = "";
102            continue;
103        }
104
105        let val = extract_stat_value(line).unwrap_or(0);
106
107        match section {
108            "tcp" => {
109                if line.contains("Active Opens") {
110                    tcp.active_opens = val;
111                } else if line.contains("Passive Opens") {
112                    tcp.passive_opens = val;
113                } else if line.contains("Failed") {
114                    tcp.failed_connections = val;
115                } else if line.contains("Reset") && line.contains("Connection") {
116                    tcp.reset_connections = val;
117                } else if line.contains("Current") {
118                    tcp.current_connections = val;
119                } else if line.contains("Segments Received") {
120                    tcp.segments_received = val;
121                } else if line.contains("Segments Sent") && !line.contains("Re") {
122                    tcp.segments_sent = val;
123                } else if line.contains("Retransmit") {
124                    tcp.segments_retransmitted = val;
125                }
126            }
127            "udp" => {
128                if line.contains("Datagrams Received") {
129                    udp.datagrams_received = val;
130                } else if line.contains("No Ports") {
131                    udp.no_port_errors = val;
132                } else if line.contains("Receive Errors") {
133                    udp.receive_errors = val;
134                } else if line.contains("Datagrams Sent") {
135                    udp.datagrams_sent = val;
136                }
137            }
138            "icmp" => {
139                if line.contains("Messages") && line.contains("Received") {
140                    icmp.messages_received = val;
141                } else if line.contains("Messages") && line.contains("Sent") {
142                    icmp.messages_sent = val;
143                } else if line.contains("Errors") && line.contains("Received") {
144                    icmp.errors_received = val;
145                } else if line.contains("Errors") && line.contains("Sent") {
146                    icmp.errors_sent = val;
147                }
148            }
149            _ => {}
150        }
151    }
152
153    Some(ProtocolStatistics { tcp, udp, icmp })
154}
155
156#[cfg(target_os = "macos")]
157async fn collect_macos() -> Option<ProtocolStatistics> {
158    let mut cmd = tokio::process::Command::new("netstat");
159    cmd.args(["-s"]);
160    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
161
162    let text = String::from_utf8_lossy(&output.stdout);
163
164    let mut stats = parse_macos_protocol_stats(&text)?;
165    let mut connections_cmd = tokio::process::Command::new("netstat");
166    connections_cmd.args(["-anW", "-p", "tcp"]);
167    let connections = super::util::run_with_timeout(connections_cmd, super::util::QUICK).await?;
168    stats.tcp.current_connections = String::from_utf8_lossy(&connections.stdout)
169        .lines()
170        .filter(|line| line.split_whitespace().last() == Some("ESTABLISHED"))
171        .count() as u64;
172    Some(stats)
173}
174
175#[cfg(any(target_os = "macos", test))]
176fn parse_macos_protocol_stats(text: &str) -> Option<ProtocolStatistics> {
177    let mut tcp = TcpStats {
178        active_opens: 0,
179        passive_opens: 0,
180        failed_connections: 0,
181        reset_connections: 0,
182        current_connections: 0,
183        segments_received: 0,
184        segments_sent: 0,
185        segments_retransmitted: 0,
186    };
187    let mut udp = UdpStats {
188        datagrams_received: 0,
189        datagrams_sent: 0,
190        receive_errors: 0,
191        no_port_errors: 0,
192    };
193    let mut icmp = IcmpStats {
194        messages_received: 0,
195        messages_sent: 0,
196        errors_received: 0,
197        errors_sent: 0,
198    };
199
200    let mut section = "";
201    let mut icmp_input_histogram = false;
202    let mut icmp_input_total = 0u64;
203    for line in text.lines() {
204        let trimmed = line.trim();
205        if trimmed == "tcp:" {
206            section = "tcp";
207            continue;
208        }
209        if trimmed == "udp:" {
210            section = "udp";
211            continue;
212        }
213        if trimmed == "icmp:" {
214            section = "icmp";
215            icmp_input_histogram = false;
216            continue;
217        }
218        if trimmed.ends_with(':') && !trimmed.contains(' ') {
219            section = "";
220            continue;
221        }
222
223        let val = extract_leading_number(trimmed).unwrap_or(0);
224
225        match section {
226            "tcp" => {
227                if trimmed.contains("connection request") {
228                    tcp.active_opens = val;
229                } else if trimmed.contains("connection accept") {
230                    tcp.passive_opens = val;
231                } else if trimmed.contains("bad connection") {
232                    tcp.failed_connections = val;
233                } else if trimmed.ends_with("bad reset") {
234                    tcp.reset_connections = val;
235                } else if trimmed.contains("data packet") && trimmed.contains("retransmitted") {
236                    tcp.segments_retransmitted = val;
237                } else if trimmed.ends_with("packet sent") || trimmed.ends_with("packets sent") {
238                    tcp.segments_sent = val;
239                } else if trimmed.ends_with("packet received")
240                    || trimmed.ends_with("packets received")
241                {
242                    tcp.segments_received = val;
243                }
244            }
245            "udp" => {
246                if trimmed.contains("datagram") && trimmed.contains("received") {
247                    udp.datagrams_received = val;
248                } else if trimmed.contains("datagram") && trimmed.contains("output") {
249                    udp.datagrams_sent = val;
250                } else if trimmed.contains("dropped due to no socket") {
251                    udp.no_port_errors = val;
252                } else if trimmed.contains("incomplete header")
253                    || trimmed.contains("bad data length")
254                    || trimmed.contains("bad checksum")
255                    || trimmed.contains("full socket buffers")
256                {
257                    udp.receive_errors = udp.receive_errors.saturating_add(val);
258                }
259            }
260            "icmp" => {
261                if trimmed == "Input histogram:" {
262                    icmp_input_histogram = true;
263                } else if trimmed.ends_with("calls to icmp_error") {
264                    icmp.errors_sent = val;
265                    icmp.messages_sent = icmp.messages_sent.saturating_add(val);
266                } else if trimmed.ends_with("message response generated") {
267                    icmp.messages_sent = icmp.messages_sent.saturating_add(val);
268                } else if icmp_input_histogram && trimmed.contains(':') {
269                    let histogram_value = trimmed
270                        .rsplit_once(':')
271                        .and_then(|(_, value)| value.trim().parse::<u64>().ok())
272                        .unwrap_or(0);
273                    icmp_input_total = icmp_input_total.saturating_add(histogram_value);
274                } else if trimmed.contains("bad code")
275                    || trimmed.contains("bad checksum")
276                    || trimmed.contains("bad length")
277                    || trimmed.contains("minimum length")
278                {
279                    icmp.errors_received = icmp.errors_received.saturating_add(val);
280                }
281            }
282            _ => {}
283        }
284    }
285    icmp.messages_received = icmp_input_total;
286
287    // Current macOS releases can expose a populated TCP connection table while
288    // `netstat -s` returns an all-zero TCP block. The public schema uses plain
289    // integers, so serializing that block would claim measured zeroes. Omit
290    // this optional technician section until the kernel supplies counters.
291    let tcp_available = tcp.active_opens > 0
292        || tcp.passive_opens > 0
293        || tcp.failed_connections > 0
294        || tcp.reset_connections > 0
295        || tcp.current_connections > 0
296        || tcp.segments_received > 0
297        || tcp.segments_sent > 0
298        || tcp.segments_retransmitted > 0;
299    tcp_available.then_some(ProtocolStatistics { tcp, udp, icmp })
300}
301
302#[cfg(target_os = "linux")]
303async fn collect_linux() -> Option<ProtocolStatistics> {
304    let mut tcp = TcpStats {
305        active_opens: 0,
306        passive_opens: 0,
307        failed_connections: 0,
308        reset_connections: 0,
309        current_connections: 0,
310        segments_received: 0,
311        segments_sent: 0,
312        segments_retransmitted: 0,
313    };
314    let mut udp = UdpStats {
315        datagrams_received: 0,
316        datagrams_sent: 0,
317        receive_errors: 0,
318        no_port_errors: 0,
319    };
320    let mut icmp = IcmpStats {
321        messages_received: 0,
322        messages_sent: 0,
323        errors_received: 0,
324        errors_sent: 0,
325    };
326
327    // Read /proc/net/snmp
328    if let Ok(content) = tokio::fs::read_to_string("/proc/net/snmp").await {
329        let lines: Vec<&str> = content.lines().collect();
330        for i in (0..lines.len()).step_by(2) {
331            if i + 1 >= lines.len() {
332                break;
333            }
334            let headers: Vec<&str> = lines[i].split_whitespace().collect();
335            let values: Vec<&str> = lines[i + 1].split_whitespace().collect();
336
337            if headers.first() == Some(&"Tcp:") && headers.len() == values.len() {
338                for (j, header) in headers.iter().enumerate() {
339                    let val: u64 = values.get(j).and_then(|s| s.parse().ok()).unwrap_or(0);
340                    match *header {
341                        "ActiveOpens" => tcp.active_opens = val,
342                        "PassiveOpens" => tcp.passive_opens = val,
343                        "AttemptFails" => tcp.failed_connections = val,
344                        "EstabResets" => tcp.reset_connections = val,
345                        "CurrEstab" => tcp.current_connections = val,
346                        "InSegs" => tcp.segments_received = val,
347                        "OutSegs" => tcp.segments_sent = val,
348                        "RetransSegs" => tcp.segments_retransmitted = val,
349                        _ => {}
350                    }
351                }
352            } else if headers.first() == Some(&"Udp:") && headers.len() == values.len() {
353                for (j, header) in headers.iter().enumerate() {
354                    let val: u64 = values.get(j).and_then(|s| s.parse().ok()).unwrap_or(0);
355                    match *header {
356                        "InDatagrams" => udp.datagrams_received = val,
357                        "OutDatagrams" => udp.datagrams_sent = val,
358                        "InErrors" => udp.receive_errors = val,
359                        "NoPorts" => udp.no_port_errors = val,
360                        _ => {}
361                    }
362                }
363            } else if headers.first() == Some(&"Icmp:") && headers.len() == values.len() {
364                for (j, header) in headers.iter().enumerate() {
365                    let val: u64 = values.get(j).and_then(|s| s.parse().ok()).unwrap_or(0);
366                    match *header {
367                        "InMsgs" => icmp.messages_received = val,
368                        "OutMsgs" => icmp.messages_sent = val,
369                        "InErrors" => icmp.errors_received = val,
370                        "OutErrors" => icmp.errors_sent = val,
371                        _ => {}
372                    }
373                }
374            }
375        }
376    }
377
378    Some(ProtocolStatistics { tcp, udp, icmp })
379}
380
381#[cfg(windows)]
382fn extract_stat_value(line: &str) -> Option<u64> {
383    // "  Active Opens              = 12345"
384    if let Some(pos) = line.find('=') {
385        let after = line[pos + 1..].trim();
386        return after.parse().ok();
387    }
388    None
389}
390
391#[cfg(any(target_os = "macos", test))]
392fn extract_leading_number(line: &str) -> Option<u64> {
393    let num_str: String = line.chars().take_while(|c| c.is_ascii_digit()).collect();
394    num_str.parse().ok()
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn current_macos_udp_wording_and_icmp_histogram_parse() {
403        let fixture = "tcp:\n\t12 packets sent\n\t3 data packets retransmitted\n\t15 packets received\n\t2 connection requests\n\t1 connection accept\nudp:\n\t409241 datagrams received\n\t\t7 dropped due to no socket\n\t\t3 dropped due to full socket buffers\n\t104267 datagrams output\nicmp:\n\t5 calls to icmp_error\n\tInput histogram:\n\t\techo reply: 8\n\t\tdestination unreachable: 2\n\t1 message response generated\n";
404        let stats = parse_macos_protocol_stats(fixture).unwrap();
405        assert_eq!(stats.tcp.segments_sent, 12);
406        assert_eq!(stats.tcp.segments_retransmitted, 3);
407        assert_eq!(stats.udp.datagrams_sent, 104267);
408        assert_eq!(stats.udp.no_port_errors, 7);
409        assert_eq!(stats.udp.receive_errors, 3);
410        assert_eq!(stats.icmp.messages_received, 10);
411        assert_eq!(stats.icmp.messages_sent, 6);
412    }
413
414    #[test]
415    fn suppressed_macos_tcp_counters_omit_optional_section() {
416        let fixture = "tcp:\n\t0 packet sent\n\t0 packet received\n\t0 connection request\n\t0 connection accept\nudp:\n\t12 datagrams received\n\t9 datagrams output\n";
417        assert!(parse_macos_protocol_stats(fixture).is_none());
418    }
419}