Skip to main content

network_inspector/
tcp.rs

1// TCP/UDP /proc/net parsing for a single process.
2//
3// Logic moved from `peek-core::proc::network` so that low-level parsing lives
4// in this crate. `peek-core` adapts these raw structs into its own
5// `NetworkInfo`, `SocketEntry`, and `ConnectionEntry` types.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::net::{Ipv4Addr, Ipv6Addr};
11
12/// One-pass: read /proc/net/{tcp,udp,tcp6,udp6} once and return inode -> (kind, local, remote) for the given port.
13pub fn inodes_using_port(port: u16) -> HashMap<u64, (String, String, String)> {
14    let mut map = HashMap::new();
15    for &is_v6 in &[false, true] {
16        for &udp in &[false, true] {
17            let proto = match (is_v6, udp) {
18                (false, false) => "TCP",
19                (false, true) => "UDP",
20                (true, false) => "TCP6",
21                (true, true) => "UDP6",
22            };
23            let path = match (is_v6, udp) {
24                (false, false) => "/proc/net/tcp",
25                (false, true) => "/proc/net/udp",
26                (true, false) => "/proc/net/tcp6",
27                (true, true) => "/proc/net/udp6",
28            };
29            if let Ok(raw) = std::fs::read_to_string(path) {
30                for line in raw.lines().skip(1) {
31                    let fields: Vec<&str> = line.split_whitespace().collect();
32                    if fields.len() < 10 {
33                        continue;
34                    }
35                    let local = parse_addr(fields[1], is_v6);
36                    let remote = parse_addr(fields[2], is_v6);
37                    if local.1 != port && remote.1 != port {
38                        continue;
39                    }
40                    let inode: u64 = fields[9].parse().unwrap_or(0);
41                    let state_hex = u8::from_str_radix(fields[3], 16).unwrap_or(0);
42                    let state = tcp_state(state_hex);
43                    let kind = if state == "LISTEN" {
44                        format!("LISTEN/{}", proto)
45                    } else {
46                        format!("CONN/{}", proto)
47                    };
48                    let local_s = format!("{}:{}", local.0, local.1);
49                    let remote_s = if state == "LISTEN" {
50                        "-".to_string()
51                    } else {
52                        format!("{}:{}", remote.0, remote.1)
53                    };
54                    map.insert(inode, (kind, local_s, remote_s));
55                }
56            }
57        }
58    }
59    map
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SocketEntry {
64    pub protocol: String,
65    pub local_addr: String,
66    pub local_port: u16,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ConnectionEntry {
71    pub protocol: String,
72    pub local_addr: String,
73    pub local_port: u16,
74    pub remote_addr: String,
75    pub remote_port: u16,
76    pub state: String,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct NetworkInfo {
81    pub listening_tcp: Vec<SocketEntry>,
82    pub listening_udp: Vec<SocketEntry>,
83    pub connections: Vec<ConnectionEntry>,
84}
85
86/// Collect listening sockets and active connections for `pid` by inspecting
87/// `/proc/<pid>/fd` and `/proc/net/{tcp,udp,tcp6,udp6}`.
88pub fn collect_network(pid: i32) -> anyhow::Result<NetworkInfo> {
89    // 1. Find socket inodes belonging to this process
90    let socket_inodes = process_socket_inodes(pid);
91
92    // 2. Parse kernel network tables
93    let mut listening_tcp = Vec::new();
94    let mut listening_udp = Vec::new();
95    let mut connections = Vec::new();
96
97    for &is_v6 in &[false, true] {
98        for &udp in &[false, true] {
99            let proto = match (is_v6, udp) {
100                (false, false) => "TCP",
101                (false, true) => "UDP",
102                (true, false) => "TCP6",
103                (true, true) => "UDP6",
104            };
105            let path = match (is_v6, udp) {
106                (false, false) => "/proc/net/tcp",
107                (false, true) => "/proc/net/udp",
108                (true, false) => "/proc/net/tcp6",
109                (true, true) => "/proc/net/udp6",
110            };
111
112            if let Ok(raw) = std::fs::read_to_string(path) {
113                for line in raw.lines().skip(1) {
114                    let fields: Vec<&str> = line.split_whitespace().collect();
115                    if fields.len() < 10 {
116                        continue;
117                    }
118                    let inode: u64 = fields[9].parse().unwrap_or(0);
119                    if !socket_inodes.contains(&inode) {
120                        continue;
121                    }
122
123                    let local = parse_addr(fields[1], is_v6);
124                    let remote = parse_addr(fields[2], is_v6);
125                    let state_hex = u8::from_str_radix(fields[3], 16).unwrap_or(0);
126                    let state = tcp_state(state_hex);
127
128                    if state == "LISTEN" {
129                        let entry = SocketEntry {
130                            protocol: proto.to_string(),
131                            local_addr: local.0,
132                            local_port: local.1,
133                        };
134                        if udp {
135                            listening_udp.push(entry);
136                        } else {
137                            listening_tcp.push(entry);
138                        }
139                    } else if !udp || remote.1 != 0 {
140                        connections.push(ConnectionEntry {
141                            protocol: proto.to_string(),
142                            local_addr: local.0,
143                            local_port: local.1,
144                            remote_addr: remote.0,
145                            remote_port: remote.1,
146                            state: state.to_string(),
147                        });
148                    }
149                }
150            }
151        }
152    }
153
154    Ok(NetworkInfo {
155        listening_tcp,
156        listening_udp,
157        connections,
158    })
159}
160
161/// Socket inodes for a process (from /proc/<pid>/fd). Used by port search.
162pub fn process_socket_inodes(pid: i32) -> HashSet<u64> {
163    let mut inodes = HashSet::new();
164    let fd_dir = format!("/proc/{}/fd", pid);
165    if let Ok(entries) = std::fs::read_dir(&fd_dir) {
166        for entry in entries.flatten() {
167            if let Ok(target) = std::fs::read_link(entry.path()) {
168                let s = target.to_string_lossy();
169                if let Some(inode_str) =
170                    s.strip_prefix("socket:[").and_then(|s| s.strip_suffix(']'))
171                {
172                    if let Ok(inode) = inode_str.parse::<u64>() {
173                        inodes.insert(inode);
174                    }
175                }
176            }
177        }
178    }
179    inodes
180}
181
182/// Parse hex address:port like "0100007F:1F40" into ("127.0.0.1", 8000).
183fn parse_addr(field: &str, is_v6: bool) -> (String, u16) {
184    let parts: Vec<&str> = field.splitn(2, ':').collect();
185    if parts.len() != 2 {
186        return ("?".to_string(), 0);
187    }
188    let port = u16::from_str_radix(parts[1], 16).unwrap_or(0);
189    let addr_hex = parts[0];
190
191    let addr = if is_v6 {
192        // Four 32-bit little-endian words
193        let bytes: Vec<u32> = addr_hex
194            .as_bytes()
195            .chunks(8)
196            .filter_map(|c| {
197                let s = std::str::from_utf8(c).ok()?;
198                u32::from_str_radix(s, 16).ok()
199            })
200            .collect();
201        if bytes.len() == 4 {
202            let b: Vec<u8> = bytes.iter().flat_map(|w| w.to_le_bytes()).collect();
203            let arr: [u8; 16] = b.try_into().unwrap_or([0; 16]);
204            Ipv6Addr::from(arr).to_string()
205        } else {
206            addr_hex.to_string()
207        }
208    } else if let Ok(n) = u32::from_str_radix(addr_hex, 16) {
209        let ip = Ipv4Addr::from(n.to_le_bytes());
210        ip.to_string()
211    } else {
212        addr_hex.to_string()
213    };
214
215    (addr, port)
216}
217
218fn tcp_state(state: u8) -> &'static str {
219    match state {
220        0x01 => "ESTABLISHED",
221        0x02 => "SYN_SENT",
222        0x03 => "SYN_RECV",
223        0x04 => "FIN_WAIT1",
224        0x05 => "FIN_WAIT2",
225        0x06 => "TIME_WAIT",
226        0x07 => "CLOSE",
227        0x08 => "CLOSE_WAIT",
228        0x09 => "LAST_ACK",
229        0x0A => "LISTEN",
230        0x0B => "CLOSING",
231        _ => "UNKNOWN",
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{parse_addr, tcp_state};
238
239    #[test]
240    fn parses_ipv4_addr_and_port() {
241        // 127.0.0.1:8000 encoded as hex
242        let (addr, port) = parse_addr("0100007F:1F40", false);
243        assert_eq!(addr, "127.0.0.1");
244        assert_eq!(port, 8000);
245    }
246
247    #[test]
248    fn parses_tcp_states() {
249        assert_eq!(tcp_state(0x01), "ESTABLISHED");
250        assert_eq!(tcp_state(0x0A), "LISTEN");
251        assert_eq!(tcp_state(0xFF), "UNKNOWN");
252    }
253}