Skip to main content

nd_300/diagnostics/
connections.rs

1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct ConnectionEntry {
7    pub protocol: String,
8    pub local_addr: String,
9    pub remote_addr: String,
10    pub state: String,
11    pub pid: Option<u32>,
12    pub process_name: Option<String>,
13}
14
15pub async fn collect_with_cache(cache: &SharedCache) -> Option<Vec<ConnectionEntry>> {
16    #[cfg(windows)]
17    {
18        if let Some(ref nc) = cache.netstat {
19            return Some(parse_windows_connections(&nc.lines, &nc.process_map));
20        }
21    }
22
23    #[cfg(target_os = "macos")]
24    {
25        if let Some(ref nc) = cache.netstat {
26            return Some(parse_macos_connections(&nc.lines));
27        }
28    }
29
30    // Linux uses `ss`, not netstat -ano, so always falls through
31    let _ = cache;
32    collect().await
33}
34
35pub async fn collect() -> Option<Vec<ConnectionEntry>> {
36    #[cfg(windows)]
37    {
38        collect_windows().await
39    }
40
41    #[cfg(target_os = "macos")]
42    {
43        collect_macos().await
44    }
45
46    #[cfg(target_os = "linux")]
47    {
48        collect_linux().await
49    }
50}
51
52#[cfg(windows)]
53fn parse_windows_connections(
54    lines: &[String],
55    process_map: &std::collections::HashMap<u32, String>,
56) -> Vec<ConnectionEntry> {
57    let mut entries = Vec::new();
58
59    for line in lines {
60        let line = line.trim();
61        let parts: Vec<&str> = line.split_whitespace().collect();
62
63        if parts.len() >= 4 && (parts[0] == "TCP" || parts[0] == "UDP") {
64            let pid = parts.last().and_then(|s| s.parse::<u32>().ok());
65            let state = if parts[0] == "TCP" && parts.len() >= 5 {
66                parts[3].to_string()
67            } else {
68                String::new()
69            };
70
71            let process_name = pid.and_then(|p| process_map.get(&p).cloned());
72
73            entries.push(ConnectionEntry {
74                protocol: parts[0].to_string(),
75                local_addr: parts[1].to_string(),
76                remote_addr: parts[2].to_string(),
77                state,
78                pid,
79                process_name,
80            });
81        }
82    }
83
84    entries
85}
86
87#[cfg(windows)]
88async fn collect_windows() -> Option<Vec<ConnectionEntry>> {
89    use sysinfo::System;
90
91    let mut cmd = tokio::process::Command::new("netstat");
92    cmd.args(["-ano"]);
93    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
94
95    let text = String::from_utf8_lossy(&output.stdout);
96    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
97
98    let mut sys = System::new();
99    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
100    let mut process_map = std::collections::HashMap::new();
101    for (pid, process) in sys.processes() {
102        process_map.insert(pid.as_u32(), process.name().to_string_lossy().to_string());
103    }
104
105    Some(parse_windows_connections(&lines, &process_map))
106}
107
108#[cfg(target_os = "macos")]
109fn parse_macos_connections(lines: &[String]) -> Vec<ConnectionEntry> {
110    let mut entries = Vec::new();
111
112    for line in lines {
113        let parts: Vec<&str> = line.split_whitespace().collect();
114        if parts.len() >= 6 && parts[0].starts_with("tcp") {
115            entries.push(ConnectionEntry {
116                protocol: "TCP".to_string(),
117                local_addr: parts[3].to_string(),
118                remote_addr: parts[4].to_string(),
119                state: parts[5].to_string(),
120                pid: None,
121                process_name: None,
122            });
123        }
124    }
125
126    entries
127}
128
129#[cfg(target_os = "macos")]
130async fn collect_macos() -> Option<Vec<ConnectionEntry>> {
131    let mut cmd = tokio::process::Command::new("netstat");
132    cmd.args(["-anp", "tcp"]);
133    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
134
135    let text = String::from_utf8_lossy(&output.stdout);
136    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
137    Some(parse_macos_connections(&lines))
138}
139
140#[cfg(target_os = "linux")]
141async fn collect_linux() -> Option<Vec<ConnectionEntry>> {
142    let mut cmd = tokio::process::Command::new("ss");
143    cmd.args(["-tupn"]);
144    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
145
146    let text = String::from_utf8_lossy(&output.stdout);
147    let mut entries = Vec::new();
148
149    for line in text.lines().skip(1) {
150        let parts: Vec<&str> = line.split_whitespace().collect();
151        if parts.len() >= 5 {
152            let (pid, pname) = parse_ss_process(parts.get(6).unwrap_or(&""));
153
154            entries.push(ConnectionEntry {
155                protocol: parts[0].to_uppercase(),
156                local_addr: parts[4].to_string(),
157                remote_addr: parts.get(5).unwrap_or(&"*:*").to_string(),
158                state: parts[1].to_string(),
159                pid,
160                process_name: pname,
161            });
162        }
163    }
164
165    Some(entries)
166}
167
168#[cfg(target_os = "linux")]
169fn parse_ss_process(s: &str) -> (Option<u32>, Option<String>) {
170    // Format: users:(("process",pid=1234,fd=5))
171    if let Some(start) = s.find("pid=") {
172        let after = &s[start + 4..];
173        let pid_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
174        let pid = pid_str.parse().ok();
175
176        let pname = if let Some(name_start) = s.find("((\"") {
177            let after = &s[name_start + 3..];
178            let name: String = after.chars().take_while(|c| *c != '"').collect();
179            Some(name)
180        } else {
181            None
182        };
183
184        (pid, pname)
185    } else {
186        (None, None)
187    }
188}