use serde::Serialize;
use crate::i18n::t;
use crate::output::{print_json, OutputMode};
use crate::table::print_table;
#[derive(Debug, Clone, Serialize)]
pub struct ConnectionInfo {
pub protocol: String,
pub local_addr: String,
pub remote_addr: String,
pub state: String,
pub pid: u32,
pub process_name: String,
}
#[derive(Serialize)]
pub struct ConnectionsOutput {
pub connections: Vec<ConnectionInfo>,
pub total: usize,
pub tcp_count: usize,
pub udp_count: usize,
}
pub struct ConnFilter {
pub state: Option<String>,
pub port: Option<u16>,
pub process: Option<String>,
}
#[cfg(target_os = "windows")]
mod conn_win;
#[cfg(target_os = "windows")]
use conn_win::get_connections;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod conn_unix;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use conn_unix::get_connections;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn parse_ss_output(text: &str) -> Vec<ConnectionInfo> {
let mut connections = Vec::new();
for line in text.lines().skip(1) {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 6 {
continue;
}
let netid = parts[0].to_lowercase();
let protocol = if netid == "udp" { "UDP" } else { "TCP" };
let state = parts[1].to_string();
let local = parts[4].to_string();
let remote = parts[5].to_string();
let mut pid = 0u32;
let mut process_name = String::from("N/A");
if parts.len() > 6 {
let proc_part = parts[6..].join(" ");
if let Some(start) = proc_part.find("((\"") {
let after = &proc_part[start + 3..];
if let Some(end) = after.find('"') {
process_name = after[..end].to_string();
}
}
if let Some(start) = proc_part.find("pid=") {
let after = &proc_part[start + 4..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(p) = num.parse::<u32>() {
pid = p;
}
}
}
let remote_addr = if remote == "*" || remote == "*:*" {
"*:*".to_string()
} else {
remote
};
let state = if protocol == "UDP" && state.eq_ignore_ascii_case("UNCONN") {
"*".to_string()
} else {
state
};
connections.push(ConnectionInfo {
protocol: protocol.to_string(),
local_addr: local,
remote_addr,
state,
pid,
process_name,
});
}
connections
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn parse_netstat_output(text: &str) -> Vec<ConnectionInfo> {
let mut connections = Vec::new();
for line in text.lines().skip(2) {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 {
continue;
}
let proto = parts[0].to_lowercase();
let protocol = if proto == "udp" { "UDP" } else { "TCP" };
let local = parts[3].to_string();
let remote = parts[4].to_string();
let (state, pid_prog) = if protocol == "UDP" {
("*".to_string(), parts.get(5).copied().unwrap_or("-"))
} else {
(
parts.get(5).copied().unwrap_or("*").to_string(),
parts.get(6).copied().unwrap_or("-"),
)
};
let (pid, process_name) = parse_pid_program(pid_prog);
let remote_addr = if remote == "*" || remote == "*:*" || remote == "0.0.0.0:*" {
"*:*".to_string()
} else {
remote
};
connections.push(ConnectionInfo {
protocol: protocol.to_string(),
local_addr: local,
remote_addr,
state,
pid,
process_name,
});
}
connections
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_pid_program(s: &str) -> (u32, String) {
if s == "-" || s.is_empty() {
return (0, String::from("N/A"));
}
if let Some(idx) = s.find('/') {
let pid = s[..idx].parse::<u32>().unwrap_or(0);
let name = s[idx + 1..].to_string();
(pid, name)
} else {
(s.parse::<u32>().unwrap_or(0), String::from("N/A"))
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn parse_lsof_output(text: &str) -> Vec<ConnectionInfo> {
let mut connections = Vec::new();
for line in text.lines().skip(1) {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
continue;
}
let process_name = parts[0].to_string();
let pid: u32 = parts[1].parse().unwrap_or(0);
let name = parts[8..].join(" ");
let (protocol, local_addr, remote_addr, state) = match parse_lsof_name(&name) {
Some(v) => v,
None => continue,
};
connections.push(ConnectionInfo {
protocol,
local_addr,
remote_addr,
state,
pid,
process_name,
});
}
connections
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_lsof_name(name: &str) -> Option<(String, String, String, String)> {
let mut rest = name.trim();
let protocol = if let Some(stripped) = rest.strip_prefix("TCP") {
rest = stripped.trim_start();
"TCP"
} else if let Some(stripped) = rest.strip_prefix("UDP") {
rest = stripped.trim_start();
"UDP"
} else {
return None;
};
let (addr_part, state) = if let Some(idx) = rest.rfind(" (") {
let s = rest[idx + 2..].trim_end_matches(')').to_string();
(rest[..idx].trim().to_string(), s)
} else {
(rest.trim().to_string(), "*".to_string())
};
let (local_addr, remote_addr) = if let Some(idx) = addr_part.find("->") {
(
addr_part[..idx].trim().to_string(),
addr_part[idx + 2..].trim().to_string(),
)
} else {
(addr_part, "*:*".to_string())
};
Some((protocol.to_string(), local_addr, remote_addr, state))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ss_parses_tcp_established_with_process() {
let input = "\
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp ESTAB 0 0 192.168.1.5:43210 142.250.69.174:443 users:((\"chrome\",pid=1234,fd=15))
";
let conns = parse_ss_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "TCP");
assert_eq!(c.local_addr, "192.168.1.5:43210");
assert_eq!(c.remote_addr, "142.250.69.174:443");
assert_eq!(c.state, "ESTAB");
assert_eq!(c.pid, 1234);
assert_eq!(c.process_name, "chrome");
}
#[test]
fn ss_parses_udp_unconn() {
let input = "\
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 0.0.0.0:53 *:* users:((\"dnsmasq\",pid=567,fd=4))
";
let conns = parse_ss_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "UDP");
assert_eq!(c.local_addr, "0.0.0.0:53");
assert_eq!(c.remote_addr, "*:*");
assert_eq!(c.state, "*");
assert_eq!(c.pid, 567);
assert_eq!(c.process_name, "dnsmasq");
}
#[test]
fn ss_skips_header_and_blank_lines() {
let input = "\
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 0 0.0.0.0:22 *:* users:((\"sshd\",pid=890,fd=3))
";
let conns = parse_ss_output(input);
assert_eq!(conns.len(), 1);
assert_eq!(conns[0].protocol, "TCP");
assert_eq!(conns[0].state, "LISTEN");
assert_eq!(conns[0].process_name, "sshd");
}
#[test]
fn netstat_parses_tcp_established() {
let input = "\
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 192.168.1.5:43210 142.250.69.174:443 ESTABLISHED 1234/chrome
";
let conns = parse_netstat_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "TCP");
assert_eq!(c.local_addr, "192.168.1.5:43210");
assert_eq!(c.remote_addr, "142.250.69.174:443");
assert_eq!(c.state, "ESTABLISHED");
assert_eq!(c.pid, 1234);
assert_eq!(c.process_name, "chrome");
}
#[test]
fn netstat_parses_udp_no_state() {
let input = "\
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
udp 0 0 0.0.0.0:53 0.0.0.0:* -
udp 0 0 0.0.0.0:68 0.0.0.0:* 567/dhclient
";
let conns = parse_netstat_output(input);
assert_eq!(conns.len(), 2);
let c0 = &conns[0];
assert_eq!(c0.protocol, "UDP");
assert_eq!(c0.local_addr, "0.0.0.0:53");
assert_eq!(c0.remote_addr, "*:*");
assert_eq!(c0.state, "*");
assert_eq!(c0.pid, 0);
assert_eq!(c0.process_name, "N/A");
let c1 = &conns[1];
assert_eq!(c1.pid, 567);
assert_eq!(c1.process_name, "dhclient");
}
#[test]
fn netstat_parses_tcp_listen_wildcard() {
let input = "\
Active Internet connections (servers and established)
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 890/sshd
";
let conns = parse_netstat_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "TCP");
assert_eq!(c.local_addr, "0.0.0.0:22");
assert_eq!(c.remote_addr, "*:*");
assert_eq!(c.state, "LISTEN");
assert_eq!(c.pid, 890);
assert_eq!(c.process_name, "sshd");
}
#[test]
fn lsof_parses_tcp_established() {
let input = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
chrome 1234 root 15u IPv4 0x12345 0t0 67890 TCP 192.168.1.5:43210->142.250.69.174:443 (ESTABLISHED)
";
let conns = parse_lsof_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "TCP");
assert_eq!(c.local_addr, "192.168.1.5:43210");
assert_eq!(c.remote_addr, "142.250.69.174:443");
assert_eq!(c.state, "ESTABLISHED");
assert_eq!(c.pid, 1234);
assert_eq!(c.process_name, "chrome");
}
#[test]
fn lsof_parses_tcp_listen_wildcard() {
let input = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
sshd 890 root 3u IPv4 0x10000 0t0 50000 TCP *:22 (LISTEN)
";
let conns = parse_lsof_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "TCP");
assert_eq!(c.local_addr, "*:22");
assert_eq!(c.remote_addr, "*:*");
assert_eq!(c.state, "LISTEN");
}
#[test]
fn lsof_parses_udp_no_state() {
let input = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
dnsmasq 567 root 4u IPv4 0x20000 0t0 60000 UDP 0.0.0.0:53
";
let conns = parse_lsof_output(input);
assert_eq!(conns.len(), 1);
let c = &conns[0];
assert_eq!(c.protocol, "UDP");
assert_eq!(c.local_addr, "0.0.0.0:53");
assert_eq!(c.remote_addr, "*:*");
assert_eq!(c.state, "*");
}
#[test]
fn lsof_ignores_non_ip_lines() {
let input = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
systemd 1 root 9u unix 0x123 0t0 30000 /run/systemd/socket type=STREAM
sshd 890 root 3u IPv4 0x10000 0t0 50000 TCP *:22 (LISTEN)
";
let conns = parse_lsof_output(input);
assert_eq!(conns.len(), 1);
assert_eq!(conns[0].protocol, "TCP");
}
}
pub fn run(filter: ConnFilter, mode: OutputMode) {
let mut connections = get_connections();
if let Some(ref state) = filter.state {
let state_upper = state.to_uppercase();
connections.retain(|c| c.state.to_uppercase() == state_upper);
}
if let Some(port) = filter.port {
connections.retain(|c| {
c.local_addr.ends_with(&format!(":{}", port))
|| c.remote_addr.ends_with(&format!(":{}", port))
});
}
if let Some(ref process) = filter.process {
let process_lower = process.to_lowercase();
connections.retain(|c| c.process_name.to_lowercase().contains(&process_lower));
}
let tcp_count = connections.iter().filter(|c| c.protocol == "TCP").count();
let udp_count = connections.iter().filter(|c| c.protocol == "UDP").count();
let total = connections.len();
let output = ConnectionsOutput {
connections: connections.clone(),
total,
tcp_count,
udp_count,
};
if mode == OutputMode::Json {
print_json(&output);
return;
}
println!();
println!("{}", t("connections.title").bold());
if connections.is_empty() {
println!(" {}", t("connections.no_result").yellow());
} else {
let h_proto = t("connections.proto");
let h_local = t("connections.local");
let h_remote = t("connections.remote");
let h_state = t("connections.state");
let h_pid = t("connections.pid");
let h_process = t("connections.process");
let headers = [
h_proto.as_str(),
h_local.as_str(),
h_remote.as_str(),
h_state.as_str(),
h_pid.as_str(),
h_process.as_str(),
];
let rows: Vec<Vec<String>> = connections
.iter()
.map(|c| {
vec![
c.protocol.clone(),
c.local_addr.clone(),
c.remote_addr.clone(),
c.state.clone(),
c.pid.to_string(),
c.process_name.clone(),
]
})
.collect();
print_table(&headers, &rows);
}
println!();
println!(
" {}",
t("connections.summary")
.replace("{0}", &total.to_string())
.replace("{1}", &tcp_count.to_string())
.replace("{2}", &udp_count.to_string())
);
println!();
println!(" {}", t("connections.no_admin").dimmed());
}
use colored::Colorize;