use crate::ssh::SshClient;
use crate::tools::ToolsError;
#[derive(Debug, Clone)]
pub struct ListeningPort {
pub protocol: String,
pub local_addr: String,
pub port: u16,
pub process: Option<String>,
pub pid: Option<u32>,
}
pub async fn listening_ports(client: &SshClient) -> Result<Vec<ListeningPort>, ToolsError> {
let cmd = "if command -v ss >/dev/null 2>&1; then \
ss -tunlpH 2>/dev/null || ss -tunlp 2>/dev/null; \
elif command -v netstat >/dev/null 2>&1; then \
netstat -tunlp 2>/dev/null; \
else \
echo 'NEITHER_SS_NOR_NETSTAT' >&2; exit 127; \
fi";
let out = client
.execute_command_full(cmd)
.await
.map_err(|e| ToolsError::SshExec(e.to_string()))?;
if out.stderr.contains("NEITHER_SS_NOR_NETSTAT") {
return Err(ToolsError::RemoteCommand {
exit: out.exit_code.map(|c| c as i32),
message: "host has neither `ss` nor `netstat` available".into(),
});
}
Ok(parse(&out.stdout))
}
fn parse(stdout: &str) -> Vec<ListeningPort> {
let mut ports = Vec::new();
for raw_line in stdout.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with("Netid")
|| line.starts_with("Active")
|| line.starts_with("Proto")
{
continue;
}
if let Some(parsed) = parse_line(line) {
ports.push(parsed);
}
}
ports
}
fn parse_line(line: &str) -> Option<ListeningPort> {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.is_empty() {
return None;
}
let proto_token = cols[0].to_lowercase();
if !(proto_token.starts_with("tcp") || proto_token.starts_with("udp")) {
return None;
}
let local = cols.iter().find(|t| {
t.contains(':')
&& t.rsplit_once(':')
.is_some_and(|(_, p)| p.parse::<u16>().is_ok())
})?;
let (local_addr, port_str) = local.rsplit_once(':')?;
let port: u16 = port_str.parse().ok()?;
let mut process: Option<String> = None;
let mut pid: Option<u32> = None;
if let Some(users_tok) = cols.iter().find(|t| t.starts_with("users:((")) {
let inner = users_tok
.trim_start_matches("users:((")
.trim_end_matches("))");
if let Some(first) = inner.split("),(").next() {
let mut parts = first.split(',');
if let Some(name) = parts.next() {
process = Some(name.trim_matches('"').to_string());
}
for part in parts {
if let Some(rest) = part.trim().strip_prefix("pid=") {
pid = rest.parse().ok();
}
}
}
} else if let Some(last) = cols.last()
&& let Some((p, name)) = last.split_once('/')
{
pid = p.parse().ok();
process = Some(name.to_string());
}
Some(ListeningPort {
protocol: proto_token,
local_addr: local_addr.trim_matches(['[', ']']).to_string(),
port,
process,
pid,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ss_output() {
let sample = "\
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:((\"dhclient\",pid=512,fd=6))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=1024,fd=3))
tcp LISTEN 0 128 [::]:22 [::]:* users:((\"sshd\",pid=1024,fd=4))
";
let parsed = parse(sample);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[1].port, 22);
assert_eq!(parsed[1].protocol, "tcp");
assert_eq!(parsed[1].process.as_deref(), Some("sshd"));
assert_eq!(parsed[1].pid, Some(1024));
assert_eq!(parsed[2].local_addr, "::");
}
#[test]
fn parses_netstat_output() {
let sample = "\
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1024/sshd
tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 900/cupsd
";
let parsed = parse(sample);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].process.as_deref(), Some("sshd"));
assert_eq!(parsed[1].port, 631);
assert_eq!(parsed[1].pid, Some(900));
}
}