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 lines) = cache.macos_lsof_tcp {
26            let entries = parse_macos_lsof_connections(lines);
27            if !entries.is_empty() {
28                return Some(entries);
29            }
30        }
31        if let Some(ref nc) = cache.netstat {
32            return Some(parse_macos_connections(&nc.lines));
33        }
34    }
35
36    // Linux uses `ss`, not netstat -ano, so always falls through
37    let _ = cache;
38    collect().await
39}
40
41pub async fn collect() -> Option<Vec<ConnectionEntry>> {
42    #[cfg(windows)]
43    {
44        collect_windows().await
45    }
46
47    #[cfg(target_os = "macos")]
48    {
49        collect_macos().await
50    }
51
52    #[cfg(target_os = "linux")]
53    {
54        collect_linux().await
55    }
56}
57
58#[cfg(windows)]
59fn parse_windows_connections(
60    lines: &[String],
61    process_map: &std::collections::HashMap<u32, String>,
62) -> Vec<ConnectionEntry> {
63    let mut entries = Vec::new();
64
65    for line in lines {
66        let line = line.trim();
67        let parts: Vec<&str> = line.split_whitespace().collect();
68
69        if parts.len() >= 4 && (parts[0] == "TCP" || parts[0] == "UDP") {
70            let pid = parts.last().and_then(|s| s.parse::<u32>().ok());
71            let state = if parts[0] == "TCP" && parts.len() >= 5 {
72                parts[3].to_string()
73            } else {
74                String::new()
75            };
76
77            let process_name = pid.and_then(|p| process_map.get(&p).cloned());
78
79            entries.push(ConnectionEntry {
80                protocol: parts[0].to_string(),
81                local_addr: parts[1].to_string(),
82                remote_addr: parts[2].to_string(),
83                state,
84                pid,
85                process_name,
86            });
87        }
88    }
89
90    entries
91}
92
93#[cfg(windows)]
94async fn collect_windows() -> Option<Vec<ConnectionEntry>> {
95    use sysinfo::System;
96
97    let mut cmd = tokio::process::Command::new("netstat");
98    cmd.args(["-ano"]);
99    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
100
101    let text = String::from_utf8_lossy(&output.stdout);
102    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
103
104    let mut sys = System::new();
105    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
106    let mut process_map = std::collections::HashMap::new();
107    for (pid, process) in sys.processes() {
108        process_map.insert(pid.as_u32(), process.name().to_string_lossy().to_string());
109    }
110
111    Some(parse_windows_connections(&lines, &process_map))
112}
113
114#[cfg(any(target_os = "macos", test))]
115fn parse_macos_connections(lines: &[String]) -> Vec<ConnectionEntry> {
116    let mut entries = Vec::new();
117
118    for line in lines {
119        let parts: Vec<&str> = line.split_whitespace().collect();
120        if parts.len() >= 6 && parts[0].starts_with("tcp") {
121            entries.push(ConnectionEntry {
122                protocol: "TCP".to_string(),
123                local_addr: parts[3].to_string(),
124                remote_addr: parts[4].to_string(),
125                state: parts[5].to_string(),
126                pid: None,
127                process_name: None,
128            });
129        }
130    }
131
132    entries
133}
134
135#[cfg(any(target_os = "macos", test))]
136#[derive(Default)]
137struct MacosLsofTcpRecord {
138    name: Option<String>,
139    state: Option<String>,
140}
141
142#[cfg(any(target_os = "macos", test))]
143fn push_macos_lsof_record(
144    entries: &mut Vec<ConnectionEntry>,
145    seen: &mut std::collections::HashSet<(String, String, String, Option<u32>)>,
146    pid: Option<u32>,
147    process_name: &Option<String>,
148    record: &mut MacosLsofTcpRecord,
149) {
150    let Some(name) = record.name.take() else {
151        record.state = None;
152        return;
153    };
154    let (local_addr, remote_addr) = name
155        .split_once("->")
156        .map(|(local, remote)| (local.to_string(), remote.to_string()))
157        .unwrap_or_else(|| (name, "*:*".to_string()));
158    let state = record.state.take().unwrap_or_default();
159    let key = (local_addr.clone(), remote_addr.clone(), state.clone(), pid);
160    if seen.insert(key) {
161        entries.push(ConnectionEntry {
162            protocol: "TCP".to_string(),
163            local_addr,
164            remote_addr,
165            state,
166            pid,
167            process_name: process_name.clone(),
168        });
169    }
170}
171
172/// Parse `lsof -nP -iTCP -FpcPnT`. A `p` line starts a process, `f` starts a
173/// socket record, `n` carries the complete endpoint, and `TST=` carries state.
174/// This is the macOS 26 fallback for `netstat`'s fixed-width IPv6 truncation.
175#[cfg(any(target_os = "macos", test))]
176fn parse_macos_lsof_connections(lines: &[String]) -> Vec<ConnectionEntry> {
177    let mut entries = Vec::new();
178    let mut seen = std::collections::HashSet::new();
179    let mut pid = None;
180    let mut process_name = None;
181    let mut record = MacosLsofTcpRecord::default();
182
183    for line in lines {
184        if let Some(value) = line.strip_prefix('p') {
185            push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
186            pid = value.parse().ok();
187            process_name = None;
188        } else if let Some(value) = line.strip_prefix('c') {
189            process_name = Some(value.to_string());
190        } else if line.starts_with('f') {
191            push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
192        } else if let Some(value) = line.strip_prefix('n') {
193            record.name = Some(value.to_string());
194        } else if let Some(value) = line.strip_prefix("TST=") {
195            record.state = Some(value.to_string());
196        }
197    }
198    push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
199
200    entries
201}
202
203#[cfg(target_os = "macos")]
204async fn collect_macos() -> Option<Vec<ConnectionEntry>> {
205    if let Some(lines) = super::shared_cache::fetch_macos_lsof_tcp_lines().await {
206        let entries = parse_macos_lsof_connections(&lines);
207        if !entries.is_empty() {
208            return Some(entries);
209        }
210    }
211
212    let mut cmd = tokio::process::Command::new("netstat");
213    cmd.args(["-anW", "-p", "tcp"]);
214    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
215
216    let text = String::from_utf8_lossy(&output.stdout);
217    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
218    Some(parse_macos_connections(&lines))
219}
220
221#[cfg(target_os = "linux")]
222async fn collect_linux() -> Option<Vec<ConnectionEntry>> {
223    let mut cmd = tokio::process::Command::new("ss");
224    cmd.args(["-tupn"]);
225    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
226
227    let text = String::from_utf8_lossy(&output.stdout);
228    let mut entries = Vec::new();
229
230    for line in text.lines().skip(1) {
231        let parts: Vec<&str> = line.split_whitespace().collect();
232        if parts.len() >= 5 {
233            let (pid, pname) = parse_ss_process(parts.get(6).unwrap_or(&""));
234
235            entries.push(ConnectionEntry {
236                protocol: parts[0].to_uppercase(),
237                local_addr: parts[4].to_string(),
238                remote_addr: parts.get(5).unwrap_or(&"*:*").to_string(),
239                state: parts[1].to_string(),
240                pid,
241                process_name: pname,
242            });
243        }
244    }
245
246    Some(entries)
247}
248
249#[cfg(target_os = "linux")]
250fn parse_ss_process(s: &str) -> (Option<u32>, Option<String>) {
251    // Format: users:(("process",pid=1234,fd=5))
252    if let Some(start) = s.find("pid=") {
253        let after = &s[start + 4..];
254        let pid_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
255        let pid = pid_str.parse().ok();
256
257        let pname = if let Some(name_start) = s.find("((\"") {
258            let after = &s[name_start + 3..];
259            let name: String = after.chars().take_while(|c| *c != '"').collect();
260            Some(name)
261        } else {
262            None
263        };
264
265        (pid, pname)
266    } else {
267        (None, None)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn macos_wide_ipv6_endpoints_are_preserved() {
277        let lines = vec![
278            "tcp6 0 0 fe80::26fd:c41b:b5bc:ff7%utun12.1024 fe80::91b5:8f5b:1234%utun12.1024 SYN_SENT"
279                .to_string(),
280        ];
281        let entries = parse_macos_connections(&lines);
282        assert_eq!(entries.len(), 1);
283        assert_eq!(
284            entries[0].local_addr,
285            "fe80::26fd:c41b:b5bc:ff7%utun12.1024"
286        );
287        assert_eq!(entries[0].state, "SYN_SENT");
288    }
289
290    #[test]
291    fn macos_26_lsof_field_output_preserves_complete_ipv6_and_process() {
292        // Captured-format fixture from macOS 26 `/usr/sbin/lsof -nP -iTCP
293        // -FpcPnT`; addresses are intentionally long enough to be truncated
294        // by Darwin `netstat`.
295        let fixture = [
296            "p7850",
297            "cidentityservicesd",
298            "f9",
299            "PTCP",
300            "n[fe80:1b::26fd:c41b:b5bc:ff7]:1024->[fe80:1b::91b5:8f5b:be19:6cf3]:1024",
301            "TST=ESTABLISHED",
302            "TQR=0",
303            "TQS=0",
304            "f10",
305            "PTCP",
306            "n*:51140",
307            "TST=LISTEN",
308        ]
309        .map(str::to_string);
310
311        let entries = parse_macos_lsof_connections(&fixture);
312        assert_eq!(entries.len(), 2);
313        assert_eq!(entries[0].local_addr, "[fe80:1b::26fd:c41b:b5bc:ff7]:1024");
314        assert_eq!(
315            entries[0].remote_addr,
316            "[fe80:1b::91b5:8f5b:be19:6cf3]:1024"
317        );
318        assert_eq!(entries[0].pid, Some(7850));
319        assert_eq!(
320            entries[0].process_name.as_deref(),
321            Some("identityservicesd")
322        );
323        assert_eq!(entries[1].remote_addr, "*:*");
324        assert_eq!(entries[1].state, "LISTEN");
325    }
326
327    #[test]
328    fn macos_lsof_duplicate_socket_records_are_deduplicated() {
329        let fixture = [
330            "p42",
331            "cdaemon",
332            "f9",
333            "PTCP",
334            "n[fd00::1]:5000->[fd00::2]:443",
335            "TST=ESTABLISHED",
336            "f10",
337            "PTCP",
338            "n[fd00::1]:5000->[fd00::2]:443",
339            "TST=ESTABLISHED",
340        ]
341        .map(str::to_string);
342        assert_eq!(parse_macos_lsof_connections(&fixture).len(), 1);
343    }
344}