ssh_commander_core/tools/
ports.rs1use crate::ssh::SshClient;
9use crate::tools::ToolsError;
10
11#[derive(Debug, Clone)]
12pub struct ListeningPort {
13 pub protocol: String,
14 pub local_addr: String,
15 pub port: u16,
16 pub process: Option<String>,
17 pub pid: Option<u32>,
18}
19
20pub async fn listening_ports(client: &SshClient) -> Result<Vec<ListeningPort>, ToolsError> {
21 let cmd = "if command -v ss >/dev/null 2>&1; then \
24 ss -tunlpH 2>/dev/null || ss -tunlp 2>/dev/null; \
25 elif command -v netstat >/dev/null 2>&1; then \
26 netstat -tunlp 2>/dev/null; \
27 else \
28 echo 'NEITHER_SS_NOR_NETSTAT' >&2; exit 127; \
29 fi";
30
31 let out = client
32 .execute_command_full(cmd)
33 .await
34 .map_err(|e| ToolsError::SshExec(e.to_string()))?;
35
36 if out.stderr.contains("NEITHER_SS_NOR_NETSTAT") {
37 return Err(ToolsError::RemoteCommand {
38 exit: out.exit_code.map(|c| c as i32),
39 message: "host has neither `ss` nor `netstat` available".into(),
40 });
41 }
42
43 Ok(parse(&out.stdout))
44}
45
46fn parse(stdout: &str) -> Vec<ListeningPort> {
47 let mut ports = Vec::new();
48 for raw_line in stdout.lines() {
49 let line = raw_line.trim();
50 if line.is_empty()
53 || line.starts_with("Netid")
54 || line.starts_with("Active")
55 || line.starts_with("Proto")
56 {
57 continue;
58 }
59 if let Some(parsed) = parse_line(line) {
60 ports.push(parsed);
61 }
62 }
63 ports
64}
65
66fn parse_line(line: &str) -> Option<ListeningPort> {
67 let cols: Vec<&str> = line.split_whitespace().collect();
68 if cols.is_empty() {
69 return None;
70 }
71
72 let proto_token = cols[0].to_lowercase();
77 if !(proto_token.starts_with("tcp") || proto_token.starts_with("udp")) {
78 return None;
79 }
80
81 let local = cols.iter().find(|t| {
86 t.contains(':')
87 && t.rsplit_once(':')
88 .is_some_and(|(_, p)| p.parse::<u16>().is_ok())
89 })?;
90
91 let (local_addr, port_str) = local.rsplit_once(':')?;
92 let port: u16 = port_str.parse().ok()?;
93
94 let mut process: Option<String> = None;
97 let mut pid: Option<u32> = None;
98 if let Some(users_tok) = cols.iter().find(|t| t.starts_with("users:((")) {
99 let inner = users_tok
101 .trim_start_matches("users:((")
102 .trim_end_matches("))");
103 if let Some(first) = inner.split("),(").next() {
105 let mut parts = first.split(',');
106 if let Some(name) = parts.next() {
107 process = Some(name.trim_matches('"').to_string());
108 }
109 for part in parts {
110 if let Some(rest) = part.trim().strip_prefix("pid=") {
111 pid = rest.parse().ok();
112 }
113 }
114 }
115 } else if let Some(last) = cols.last()
116 && let Some((p, name)) = last.split_once('/')
117 {
118 pid = p.parse().ok();
119 process = Some(name.to_string());
120 }
121
122 Some(ListeningPort {
123 protocol: proto_token,
124 local_addr: local_addr.trim_matches(['[', ']']).to_string(),
125 port,
126 process,
127 pid,
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn parses_ss_output() {
137 let sample = "\
138Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
139udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:((\"dhclient\",pid=512,fd=6))
140tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=1024,fd=3))
141tcp LISTEN 0 128 [::]:22 [::]:* users:((\"sshd\",pid=1024,fd=4))
142";
143 let parsed = parse(sample);
144 assert_eq!(parsed.len(), 3);
145 assert_eq!(parsed[1].port, 22);
146 assert_eq!(parsed[1].protocol, "tcp");
147 assert_eq!(parsed[1].process.as_deref(), Some("sshd"));
148 assert_eq!(parsed[1].pid, Some(1024));
149 assert_eq!(parsed[2].local_addr, "::");
150 }
151
152 #[test]
153 fn parses_netstat_output() {
154 let sample = "\
155Active Internet connections (only servers)
156Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
157tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1024/sshd
158tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 900/cupsd
159";
160 let parsed = parse(sample);
161 assert_eq!(parsed.len(), 2);
162 assert_eq!(parsed[0].process.as_deref(), Some("sshd"));
163 assert_eq!(parsed[1].port, 631);
164 assert_eq!(parsed[1].pid, Some(900));
165 }
166}