Skip to content

Commit cce43ee

Browse files
authored
feat: Implement Linux Port Scanner (#58)
* refactor: move get_process_commands to utils.rs * feat: Implement Linux Port Scanner
1 parent a600470 commit cce43ee

File tree

4 files changed

+270
-60
lines changed

4 files changed

+270
-60
lines changed

backend/core/src/scanner/darwin.rs

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use super::Scanner;
1313
/// macOS-specific port scanner using lsof.
1414
pub struct DarwinScanner;
1515

16+
use super::utils::Utils;
17+
1618
impl DarwinScanner {
1719
/// Create a new macOS scanner.
1820
pub fn new() -> Self {
@@ -139,7 +141,7 @@ impl DarwinScanner {
139141
.unwrap_or_else(|| process_name.clone());
140142

141143
// Parse address and port
142-
let (address, port) = match self.parse_address(&address_part) {
144+
let (address, port) = match Utils::parse_address(&address_part) {
143145
Some((a, p)) => (a, p),
144146
None => continue,
145147
};
@@ -164,33 +166,6 @@ impl DarwinScanner {
164166
ports.sort_by_key(|p| p.port);
165167
ports
166168
}
167-
168-
/// Parse an address:port string.
169-
///
170-
/// Handles multiple address formats:
171-
/// - IPv4: "127.0.0.1:3000" or "*:8080"
172-
/// - IPv6: "[::1]:3000" or "[fe80::1]:8080"
173-
fn parse_address(&self, address: &str) -> Option<(String, u16)> {
174-
if address.starts_with('[') {
175-
// IPv6 format: [::1]:3000
176-
let bracket_end = address.find(']')?;
177-
if bracket_end + 1 >= address.len() || address.as_bytes()[bracket_end + 1] != b':' {
178-
return None;
179-
}
180-
let addr = &address[..=bracket_end];
181-
let port_str = &address[bracket_end + 2..];
182-
let port: u16 = port_str.parse().ok()?;
183-
Some((addr.to_string(), port))
184-
} else {
185-
// IPv4 format: 127.0.0.1:3000 or *:8080
186-
let last_colon = address.rfind(':')?;
187-
let addr = &address[..last_colon];
188-
let port_str = &address[last_colon + 1..];
189-
let port: u16 = port_str.parse().ok()?;
190-
let addr = if addr.is_empty() { "*" } else { addr };
191-
Some((addr.to_string(), port))
192-
}
193-
}
194169
}
195170

196171
impl Default for DarwinScanner {
@@ -231,32 +206,6 @@ impl Scanner for DarwinScanner {
231206
mod tests {
232207
use super::*;
233208

234-
#[test]
235-
fn test_parse_ipv4_address() {
236-
let scanner = DarwinScanner::new();
237-
238-
let (addr, port) = scanner.parse_address("127.0.0.1:3000").unwrap();
239-
assert_eq!(addr, "127.0.0.1");
240-
assert_eq!(port, 3000);
241-
242-
let (addr, port) = scanner.parse_address("*:8080").unwrap();
243-
assert_eq!(addr, "*");
244-
assert_eq!(port, 8080);
245-
}
246-
247-
#[test]
248-
fn test_parse_ipv6_address() {
249-
let scanner = DarwinScanner::new();
250-
251-
let (addr, port) = scanner.parse_address("[::1]:3000").unwrap();
252-
assert_eq!(addr, "[::1]");
253-
assert_eq!(port, 3000);
254-
255-
let (addr, port) = scanner.parse_address("[fe80::1]:8080").unwrap();
256-
assert_eq!(addr, "[fe80::1]");
257-
assert_eq!(port, 8080);
258-
}
259-
260209
#[test]
261210
fn test_parse_lsof_output() {
262211
let scanner = DarwinScanner::new();

backend/core/src/scanner/linux.rs

Lines changed: 208 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,153 @@
55
66
use crate::error::{Error, Result};
77
use crate::models::PortInfo;
8-
8+
use regex::Regex;
9+
use std::collections::{HashMap, HashSet};
10+
use std::process::Stdio;
11+
use tokio::process::Command;
12+
use super::utils::Utils;
913
use super::Scanner;
1014

1115
/// Linux-specific port scanner.
1216
pub struct LinuxScanner;
1317

18+
struct LinuxProcessInfo {
19+
user: String,
20+
command: String
21+
}
22+
1423
impl LinuxScanner {
24+
1525
/// Create a new Linux scanner.
1626
pub fn new() -> Self {
1727
Self
1828
}
29+
30+
/// Get full command information and user for all processes using the ps command.
31+
///
32+
/// Executes: `ps -axo pid.user.command --no-headers`
33+
///
34+
/// Commands longer than 200 characters are truncated.
35+
async fn get_process_infos(&self) -> HashMap<u32, LinuxProcessInfo> {
36+
let output = match Command::new("/bin/ps")
37+
.args(["-axo", "pid,user,command", "--no-headers"])
38+
.stdout(Stdio::piped())
39+
.stderr(Stdio::null())
40+
.output()
41+
.await
42+
{
43+
Ok(output) => output,
44+
Err(_) => return HashMap::new(),
45+
};
46+
47+
let stdout = match String::from_utf8(output.stdout) {
48+
Ok(s) => s,
49+
Err(_) => return HashMap::new(),
50+
};
51+
52+
let mut infos = HashMap::new();
53+
54+
for line in stdout.lines() {
55+
// Split into PID and user
56+
let parts: Vec<&str> = line.split_whitespace().collect();
57+
if parts.len() != 3 {
58+
continue;
59+
}
60+
61+
let pid: u32 = match parts[0].parse() {
62+
Ok(p) => p,
63+
Err(_) => continue,
64+
};
65+
66+
let user = parts[1].to_string();
67+
68+
let command = parts[2].to_string();
69+
let command = if command.len() > 200 {
70+
format!("{}...", &command[..200])
71+
} else {
72+
command.to_string()
73+
};
74+
75+
let info = LinuxProcessInfo { user, command };
76+
infos.insert(pid, info);
77+
}
78+
79+
infos
80+
}
81+
82+
/// Parse ss output into PortInfo objects.
83+
///
84+
/// Expected ss output format:
85+
/// ```text
86+
/// State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
87+
/// LISTEN 0 4096 [::ffff:127.0.0.1]:63342 *:* users:(("rustrover",pid=53561,fd=54))
88+
/// ```
89+
fn parse_ss_output(&self, output: &str, process_infos: &HashMap<u32, LinuxProcessInfo>) -> Vec<PortInfo> {
90+
let mut ports = Vec::new();
91+
let mut seen: HashSet<(u16, u32)> = HashSet::new();
92+
93+
for line in output.lines() {
94+
if line.is_empty() {
95+
continue;
96+
}
97+
98+
// Parse columns: [State] [Recv-Q] [Send-Q] [Local Address:Port] [Peer Address:Port] [Process]
99+
let components: Vec<&str> = line.split_whitespace().collect();
100+
if components.len() < 6 {
101+
continue;
102+
}
103+
104+
let regex = Regex::new(r#"users:\(\("(.+?)",pid=(\d*),fd=(.+?)\)"#).unwrap();
105+
let Some(caps) = regex.captures(components[5]) else {
106+
continue;
107+
};
108+
109+
// Extract process name
110+
let process_name = caps[1].to_string();
111+
112+
// Parse PID
113+
let pid: u32 = match caps[2].parse() {
114+
Ok(p) => p,
115+
Err(_) => continue,
116+
};
117+
118+
// Get process info from pid
119+
let Some(info) = process_infos.get(&pid) else {
120+
continue;
121+
};
122+
123+
let user = info.user.clone();
124+
let fd = caps[2].to_string();
125+
126+
// Get full command from process info
127+
let command = info.command.clone();
128+
129+
// Parse address and port
130+
let (address, port) = match Utils::parse_address(&components[3]) {
131+
Some((a, p)) => (a, p),
132+
None => continue,
133+
};
134+
135+
// Deduplicate by (port, pid)
136+
if !seen.insert((port, pid)) {
137+
continue;
138+
}
139+
140+
ports.push(PortInfo::active(
141+
port,
142+
pid,
143+
process_name,
144+
address,
145+
user,
146+
command,
147+
fd,
148+
));
149+
}
150+
151+
// Sort by port number
152+
ports.sort_by_key(|p| p.port);
153+
ports
154+
}
19155
}
20156

21157
impl Default for LinuxScanner {
@@ -27,11 +163,77 @@ impl Default for LinuxScanner {
27163
impl Scanner for LinuxScanner {
28164
/// Scan all listening TCP ports.
29165
///
30-
/// Uses `ss -tlnp` command on Linux.
166+
/// Executes: `ss -Htlnp`
167+
///
168+
/// Flags explained:
169+
/// -H, --no-header Suppress header line
170+
/// -t, --tcp display only TCP sockets
171+
/// -l, --listening display listening sockets
172+
/// -n, --numeric don't resolve service names
173+
/// -p, --processes show process using socket
31174
async fn scan(&self) -> Result<Vec<PortInfo>> {
32-
// TODO: Implement Linux-specific scanning using ss or netstat
33-
Err(Error::UnsupportedPlatform(
34-
"Linux scanner not yet implemented".to_string(),
35-
))
175+
let output = Command::new("/usr/sbin/ss")
176+
.args(["-Htlnp"])
177+
.stdout(Stdio::piped())
178+
.stderr(Stdio::null())
179+
.output()
180+
.await
181+
.map_err(|e| Error::CommandFailed(format!("Failed to run ss: {}", e)))?;
182+
183+
let stdout = String::from_utf8(output.stdout)
184+
.map_err(|e| Error::ParseError(format!("Invalid UTF-8 in ss output: {}", e)))?;
185+
186+
let process_infos = self.get_process_infos().await;
187+
188+
Ok(self.parse_ss_output(&stdout, &process_infos))
189+
}
190+
}
191+
192+
#[cfg(test)]
193+
mod tests {
194+
use super::*;
195+
196+
#[test]
197+
fn test_parse_ss_output() {
198+
let scanner = LinuxScanner::new();
199+
let mut commands = HashMap::new();
200+
commands.insert(55316, LinuxProcessInfo {
201+
user: "user".to_string(),
202+
command: "nginx".to_string(),
203+
});
204+
commands.insert(53561, LinuxProcessInfo {
205+
user: "user".to_string(),
206+
command: "node".to_string(),
207+
});
208+
209+
let output = r#"LISTEN 0 4096 [::ffff:127.0.0.1]:80 *:* users:(("nginx",pid=55316,fd=6))
210+
LISTEN 0 50 [::ffff:127.0.0.1]:3000 *:* users:(("node",pid=53561,fd=187))"#;
211+
212+
let ports = scanner.parse_ss_output(output, &commands);
213+
assert_eq!(ports.len(), 2);
214+
215+
// Should be sorted by port
216+
assert_eq!(ports[0].port, 80);
217+
assert_eq!(ports[0].process_name, "nginx");
218+
219+
assert_eq!(ports[1].port, 3000);
220+
assert_eq!(ports[1].process_name, "node");
221+
}
222+
223+
#[test]
224+
fn test_deduplication() {
225+
let scanner = LinuxScanner::new();
226+
let mut commands = HashMap::new();
227+
commands.insert(1234, LinuxProcessInfo {
228+
user: "user".to_string(),
229+
command: "code linux.rs".to_string(),
230+
});
231+
232+
// Same port and PID should be deduplicated
233+
let output = r#"LISTEN 0 4096 127.0.0.1:3000 :* users:(("code",pid=1234,fd=54))
234+
LISTEN 0 4096 [::ffff:127.0.0.1]:3000 *:* users:(("code",pid=1234,fd=54))"#;
235+
236+
let ports = scanner.parse_ss_output(output, &commands);
237+
assert_eq!(ports.len(), 1);
36238
}
37239
}

backend/core/src/scanner/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod linux;
88

99
#[cfg(target_os = "windows")]
1010
mod windows;
11+
mod utils;
1112

1213
use crate::error::Result;
1314
use crate::models::PortInfo;

backend/core/src/scanner/utils.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
pub struct Utils;
2+
3+
impl Utils {
4+
/// Parse an address:port string.
5+
///
6+
/// Handles multiple address formats:
7+
/// - IPv4: "127.0.0.1:3000" or "*:8080"
8+
/// - IPv6: "\[::1]:3000" or "\[fe80::1]:8080"
9+
pub fn parse_address(address: &str) -> Option<(String, u16)> {
10+
if address.starts_with('[') {
11+
// IPv6 format: [::1]:3000
12+
let bracket_end = address.find(']')?;
13+
if bracket_end + 1 >= address.len() || address.as_bytes()[bracket_end + 1] != b':' {
14+
return None;
15+
}
16+
let addr = &address[..=bracket_end];
17+
let port_str = &address[bracket_end + 2..];
18+
let port: u16 = port_str.parse().ok()?;
19+
Some((addr.to_string(), port))
20+
} else {
21+
// IPv4 format: 127.0.0.1:3000 or *:8080
22+
let last_colon = address.rfind(':')?;
23+
let addr = &address[..last_colon];
24+
let port_str = &address[last_colon + 1..];
25+
let port: u16 = port_str.parse().ok()?;
26+
let addr = if addr.is_empty() { "*" } else { addr };
27+
Some((addr.to_string(), port))
28+
}
29+
}
30+
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use super::*;
36+
37+
#[test]
38+
fn test_parse_ipv4_address() {
39+
let (addr, port) = Utils::parse_address("127.0.0.1:3000").unwrap();
40+
assert_eq!(addr, "127.0.0.1");
41+
assert_eq!(port, 3000);
42+
43+
let (addr, port) = Utils::parse_address("*:8080").unwrap();
44+
assert_eq!(addr, "*");
45+
assert_eq!(port, 8080);
46+
}
47+
48+
#[test]
49+
fn test_parse_ipv6_address() {
50+
let (addr, port) = Utils::parse_address("[::1]:3000").unwrap();
51+
assert_eq!(addr, "[::1]");
52+
assert_eq!(port, 3000);
53+
54+
let (addr, port) = Utils::parse_address("[fe80::1]:8080").unwrap();
55+
assert_eq!(addr, "[fe80::1]");
56+
assert_eq!(port, 8080);
57+
}
58+
}

0 commit comments

Comments
 (0)