Skip to main content

ssh_commander_core/tools/
ports.rs

1//! Listening-port inventory for one host.
2//!
3//! Strategy: prefer `ss -tunlpH` (Linux iproute2 — header-suppressed).
4//! Fall back to `ss -tunlp` and skip the header line if `H` isn't
5//! supported by an old version. Final fallback: `netstat -tunlp` for
6//! BSDs / very old distros.
7
8use 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    // Try ss first; if it's missing, fall back to netstat. We pipe stderr
22    // into stdout so a user-friendly message survives the round-trip.
23    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        // Skip headers from ss/netstat. ss header starts with "Netid";
51        // netstat -tunlp starts with "Active" or "Proto".
52        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    // Heuristic: ss output has 6 columns
73    //   Netid State Recv-Q Send-Q LocalAddr:Port PeerAddr:Port [users:...]
74    // netstat output has 7 columns
75    //   Proto Recv-Q Send-Q LocalAddr ForeignAddr State PID/Process
76    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    // Find the local-address token: must contain ':' and have a numeric
82    // tail (i.e. looks like "addr:port" or "[::]:port"). The colon
83    // requirement excludes Recv-Q/Send-Q numeric columns that would
84    // otherwise parse as u16 on their own.
85    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    // Process info: ss puts it in a "users:((\"sshd\",pid=1234,fd=3))" token,
95    // netstat puts it in a final "1234/sshd" token.
96    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        // Trim leading `users:((` and trailing `))`.
100        let inner = users_tok
101            .trim_start_matches("users:((")
102            .trim_end_matches("))");
103        // Pieces separated by ),( — usually just one.
104        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}