55
66use crate :: error:: { Error , Result } ;
77use 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 ;
913use super :: Scanner ;
1014
1115/// Linux-specific port scanner.
1216pub struct LinuxScanner ;
1317
18+ struct LinuxProcessInfo {
19+ user : String ,
20+ command : String
21+ }
22+
1423impl 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
21157impl Default for LinuxScanner {
@@ -27,11 +163,77 @@ impl Default for LinuxScanner {
27163impl 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}
0 commit comments