use colored::Colorize;
use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::net::IpAddr;
use std::path::PathBuf;
use std::process::Output;
use std::time::{Duration, Instant};
use tokio::process::Command;
use crate::logging::{log_info, log_timed, LogLevel};
use crate::sdk::nginx::inspect_nginx_configs;
use crate::strategies::XbpConfig;
#[cfg(not(target_os = "windows"))]
use crate::utils::command_exists;
use crate::utils::{
collect_known_xbp_projects, collect_listening_port_ownership, find_xbp_config_upwards,
parse_config_with_auto_heal,
};
use tracing::{debug, error, info, warn};
fn state_sort_order(state: &str) -> u8 {
let base = state.split_whitespace().next().unwrap_or(state);
match base.to_uppercase().as_str() {
"ESTABLISHED" => 0,
"LISTEN" => 1,
"SYNSENT" | "SYN_SENT" => 2,
"SYNRECEIVED" | "SYN_RECEIVED" => 3,
"TIMEWAIT" | "TIME_WAIT" => 4,
"CLOSEWAIT" | "CLOSE_WAIT" => 5,
_ => 6,
}
}
pub async fn run_ports(args: &[String], debug: bool) -> Result<(), String> {
let mut port_filter: Option<String> = None;
let mut kill: bool = false;
let mut nginx_search: bool = false;
let mut full_view: bool = false;
let mut no_local: bool = false;
let mut exposure: bool = false;
let mut i: usize = 0;
while i < args.len() {
match args[i].as_str() {
"-p" => {
if let Some(p) = args.get(i + 1) {
port_filter = Some(p.clone());
i += 2;
} else {
return Err("-p requires a port value".to_string());
}
}
"--kill" => {
kill = true;
i += 1;
}
"-n" | "--nginx" => {
nginx_search = true;
i += 1;
}
"--full" => {
full_view = true;
i += 1;
}
"--no-local" => {
no_local = true;
i += 1;
}
"--exposure" => {
exposure = true;
i += 1;
}
_ => {
i += 1;
}
}
}
if debug {
info!("Debug mode enabled");
debug!("Args: {:?}", args);
}
let _ = log_info("ports", "Executing ports command", port_filter.as_deref()).await;
let start: Instant = Instant::now();
let command_output: String =
execute_ports_command_netstat2(port_filter.clone(), debug, kill, no_local).await;
let elapsed: Duration = start.elapsed();
let _ = log_timed(
LogLevel::Success,
"ports",
"Ports command completed",
elapsed.as_millis() as u64,
)
.await;
if debug {
debug!("execute_ports_command took: {:.2?}", elapsed);
}
if command_output.trim().is_empty() {
if let Some(port) = port_filter.clone() {
println!("No active processes found on port: {}", port);
} else {
println!("No listening TCP sockets found.");
}
} else {
display_output(command_output);
}
if nginx_search || full_view {
print_reconciled_ports(port_filter.as_deref()).await?;
if nginx_search {
if let Some(ref port) = port_filter {
info!("Searching NGINX configurations for port: {}", port);
search_nginx_configs(port).await;
}
}
}
let should_show_exposure = exposure || full_view || port_filter.is_some();
if should_show_exposure {
let requested_port = port_filter
.as_deref()
.and_then(|value| value.parse::<u16>().ok());
print_port_exposure_diagnostics(requested_port, debug).await?;
}
Ok(())
}
async fn execute_ports_command_netstat2(
port_filter: Option<String>,
debug: bool,
kill: bool,
no_local: bool,
) -> String {
let start: Instant = Instant::now();
if let Some(ref port) = port_filter {
return get_port_info_with_netstat(port, debug, kill, no_local).await;
}
let af_flags: AddressFamilyFlags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags: ProtocolFlags = ProtocolFlags::TCP;
let sockets: Vec<netstat2::SocketInfo> = match get_sockets_info(af_flags, proto_flags) {
Ok(s) => s,
Err(e) => {
return format!("Failed to get sockets info: {}", e);
}
};
if debug {
debug!("Fetched {} TCP sockets", sockets.len());
}
let mut table_output: String = String::new();
let mut port_map: BTreeMap<u16, Vec<(&netstat2::SocketInfo, &netstat2::TcpSocketInfo)>> =
BTreeMap::new();
for socket in &sockets {
if let ProtocolSocketInfo::Tcp(ref tcp_info) = socket.protocol_socket_info {
port_map
.entry(tcp_info.local_port)
.or_default()
.push((socket, tcp_info));
}
}
let mut all_rows: Vec<(u16, String, String, String, String, HashSet<u32>)> = Vec::new();
let mut all_pids_to_kill: HashMap<u16, HashSet<u32>> = HashMap::new();
for (port, entries) in &port_map {
let mut groups: HashMap<(String, String, String), (usize, HashSet<u32>)> = HashMap::new();
let mut pids_to_kill: HashSet<u32> = HashSet::new();
for (socket, tcp_info) in entries {
if no_local && tcp_info.local_addr == tcp_info.remote_addr {
continue;
}
let state_str = format!("{:?}", tcp_info.state);
let key = (
tcp_info.local_addr.to_string(),
tcp_info.remote_addr.to_string(),
state_str,
);
let (count, group_pids) = groups.entry(key).or_insert((0, HashSet::new()));
*count += 1;
for pid in &socket.associated_pids {
pids_to_kill.insert(*pid);
group_pids.insert(*pid);
}
}
if groups.is_empty() {
continue;
}
all_pids_to_kill.insert(*port, pids_to_kill);
for ((local_addr, remote_addr, state), (count, group_pids)) in groups {
let pids: String = if group_pids.is_empty() {
"-".to_string()
} else {
let mut v: Vec<_> = group_pids.iter().map(|p| p.to_string()).collect();
v.sort();
v.join(",")
};
let count_suffix = if count > 1 {
format!(" (×{})", count)
} else {
String::new()
};
all_rows.push((
*port,
pids,
local_addr,
remote_addr,
format!("{}{}", state, count_suffix),
group_pids,
));
}
}
if !all_rows.is_empty() {
all_rows.sort_by(|a, b| {
state_sort_order(&a.4)
.cmp(&state_sort_order(&b.4))
.then_with(|| a.1.cmp(&b.1)) .then_with(|| a.0.cmp(&b.0)) .then_with(|| a.2.cmp(&b.2)) .then_with(|| a.3.cmp(&b.3)) });
let sep = "+--------+----------+--------------------+--------------------+------------+";
table_output.push_str(&format!(" {}\n", sep));
table_output.push_str(&format!(
" | {:<6} | {:<8} | {:<18} | {:<18} | {:<10} |\n",
"Port".dimmed(),
"PID".dimmed(),
"LocalAddr".dimmed(),
"RemoteAddr".dimmed(),
"State".dimmed()
));
table_output.push_str(&format!(" {}\n", sep));
for (port, pids, local_addr, remote_addr, state, _) in &all_rows {
let state_base = state.split_whitespace().next().unwrap_or(state);
let state_colored = color_state(state_base);
let suffix = state.strip_prefix(state_base).unwrap_or("").trim();
let state_display = if suffix.is_empty() {
state_colored.to_string()
} else {
format!("{} {}", state_colored, suffix)
};
table_output.push_str(&format!(
" | {:<6} | {:<8} | {:<18} | {:<18} | {:<10} |\n",
port, pids, local_addr, remote_addr, state_display
));
}
table_output.push_str(&format!(" {}\n", sep));
}
if kill {
for (port, pids_to_kill) in all_pids_to_kill {
if !pids_to_kill.is_empty() && debug {
debug!(
"Found {} unique PID(s) on port {}: {:?}",
pids_to_kill.len(),
port,
pids_to_kill
);
}
for pid in &pids_to_kill {
let killed = kill_process_with_debug(&pid.to_string(), debug).await;
if killed {
info!("Successfully killed process with PID: {}", pid);
table_output.push_str(&format!("Killed process with PID: {}\n", pid));
} else {
error!("Failed to kill process with PID: {}", pid);
}
}
}
}
if debug {
debug!(
"execute_ports_command_netstat2 took: {:.2?}",
start.elapsed()
);
}
table_output
}
async fn get_port_info_with_netstat(port: &str, debug: bool, kill: bool, no_local: bool) -> String {
if debug {
debug!("Using netstat to get PIDs for port: {}", port);
}
let output: Result<Output, std::io::Error> = if cfg!(target_os = "windows") {
Command::new("netstat").arg("-ano").output().await
} else {
let netstat_cmd: String = format!("sudo netstat -tulpen | grep :{}", port);
Command::new("sh")
.arg("-c")
.arg(&netstat_cmd)
.output()
.await
};
let output = match output {
Ok(o) => o,
Err(e) => {
return format!("Failed to execute netstat: {}", e);
}
};
if !output.status.success() && output.stdout.is_empty() {
return format!("No processes found on port: {}", port);
}
let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
let port_marker = format!(":{}", port);
let filtered_lines: Vec<String> = if cfg!(target_os = "windows") {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("Proto")
|| trimmed.starts_with("Active Connections")
{
return None;
}
if trimmed.contains(&port_marker) {
Some(trimmed.to_string())
} else {
None
}
})
.collect()
} else {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect()
};
if filtered_lines.is_empty() {
return format!("No processes found on port: {}", port);
}
let mut groups: HashMap<(String, String, String), (usize, HashSet<String>)> = HashMap::new();
let mut pids_to_kill: HashSet<String> = HashSet::new();
for line in filtered_lines {
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let (pid, local_addr, foreign_addr, state) = if cfg!(target_os = "windows") {
let pid_part = parts.last().unwrap_or(&"-");
let local = parts.get(1).unwrap_or(&"-");
let foreign = parts.get(2).unwrap_or(&"-");
let state = parts.iter().rev().nth(1).unwrap_or(&"-");
(
pid_part.to_string(),
local.to_string(),
foreign.to_string(),
state.to_string(),
)
} else {
let pid_program = parts.last().unwrap_or(&"-");
let pid_value = if let Some(slash_pos) = pid_program.find('/') {
pid_program[..slash_pos].to_string()
} else {
pid_program.to_string()
};
let local = parts.get(3).unwrap_or(&"-");
let foreign = parts.get(4).unwrap_or(&"-");
let state = parts.iter().rev().nth(1).unwrap_or(&"-");
(
pid_value,
local.to_string(),
foreign.to_string(),
state.to_string(),
)
};
if no_local && local_addr == foreign_addr {
continue;
}
let key = (local_addr.clone(), foreign_addr.clone(), state.clone());
let (count, pids) = groups.entry(key).or_insert((0, HashSet::new()));
*count += 1;
if pid != "-" && pid.parse::<u32>().is_ok() {
pids_to_kill.insert(pid.clone());
pids.insert(pid.clone());
}
}
if groups.is_empty() {
return format!(
"No processes found on port: {} (filtered by --no-local)",
port
);
}
let sep = "+--------+----------+--------------------+--------------------+------------+";
let mut table_output = String::new();
table_output.push_str(&format!(" {}\n", sep));
table_output.push_str(&format!(
" | {:<6} | {:<8} | {:<18} | {:<18} | {:<10} |\n",
"Port".dimmed(),
"PID".dimmed(),
"LocalAddr".dimmed(),
"RemoteAddr".dimmed(),
"State".dimmed()
));
table_output.push_str(&format!(" {}\n", sep));
let mut group_vec: Vec<_> = groups.into_iter().collect();
group_vec.sort_by(|a, b| {
state_sort_order(&a.0 .2)
.cmp(&state_sort_order(&b.0 .2))
.then_with(|| a.0 .0.cmp(&b.0 .0))
.then_with(|| a.0 .1.cmp(&b.0 .1))
});
for ((local_addr, remote_addr, state), (count, group_pids)) in group_vec {
let pids_str = if group_pids.is_empty() {
"-".to_string()
} else {
let mut v: Vec<_> = group_pids.iter().cloned().collect();
v.sort();
v.join(",")
};
let count_suffix = if count > 1 {
format!(" (×{})", count)
} else {
String::new()
};
let state_base = state.split_whitespace().next().unwrap_or(&state);
let state_colored = color_state(state_base);
let suffix = state.strip_prefix(state_base).unwrap_or("").trim();
let state_display = if suffix.is_empty() {
format!("{}{}", state_colored, count_suffix)
} else {
format!("{} {}{}", state_colored, suffix, count_suffix)
};
table_output.push_str(&format!(
" | {:<6} | {:<8} | {:<18} | {:<18} | {:<10} |\n",
port, pids_str, local_addr, remote_addr, state_display
));
}
table_output.push_str(&format!(" {}\n", sep));
if kill && !pids_to_kill.is_empty() {
if debug {
debug!(
"Found {} unique PID(s) to kill: {:?}",
pids_to_kill.len(),
pids_to_kill
);
}
for pid in &pids_to_kill {
let killed = kill_process_with_debug(pid, debug).await;
if killed {
info!("Successfully killed process with PID: {}", pid);
table_output.push_str(&format!("Killed process with PID: {}\n", pid));
} else {
error!("Failed to kill process with PID: {}", pid);
}
}
}
table_output
}
#[cfg(target_os = "windows")]
async fn kill_process_with_debug(pid: &str, debug: bool) -> bool {
if debug {
debug!("Attempting to kill PID: {}", pid);
}
let start: Instant = Instant::now();
let kill_output = Command::new("taskkill")
.arg("/PID")
.arg(pid)
.arg("/F")
.output()
.await;
let kill_output = match kill_output {
Ok(o) => o,
Err(e) => {
if debug {
debug!("Failed to execute taskkill: {}", e);
}
return false;
}
};
let elapsed = start.elapsed();
if debug {
debug!(
"taskkill output: status={:?}, stdout='{}', stderr='{}', took: {:.2?}",
kill_output.status,
String::from_utf8_lossy(&kill_output.stdout),
String::from_utf8_lossy(&kill_output.stderr),
elapsed
);
}
kill_output.status.success()
}
#[cfg(not(target_os = "windows"))]
async fn kill_process_with_debug(pid: &str, debug: bool) -> bool {
if debug {
debug!("Attempting to kill PID: {}", pid);
}
let start: Instant = Instant::now();
let kill_output: Output = Command::new("sh")
.arg("-c")
.arg(format!("sudo kill -9 {}", pid))
.output()
.await
.expect("Failed to execute kill command");
let elapsed = start.elapsed();
if debug {
debug!(
"kill_process output: status={:?}, stdout='{}', stderr='{}', took: {:.2?}",
kill_output.status,
String::from_utf8_lossy(&kill_output.stdout),
String::from_utf8_lossy(&kill_output.stderr),
elapsed
);
}
kill_output.status.success()
}
fn color_state(state: &str) -> colored::ColoredString {
match state {
"Listen" | "LISTEN" => state.green(),
"Established" | "ESTABLISHED" => state.cyan(),
"SynReceived" | "SYN_RECEIVED" | "SynSent" | "SYN_SENT" => state.yellow(),
"TimeWait" | "TIME_WAIT" | "CloseWait" | "CLOSE_WAIT" | "FinWait1" | "FinWait2"
| "Closing" | "LastAck" => state.dimmed(),
_ => state.normal(),
}
}
fn display_output(output: String) {
println!("{}", output);
}
async fn search_nginx_configs(port: &str) {
let nginx_sites_available_path = PathBuf::from("/etc/nginx/sites-available");
if !nginx_sites_available_path.exists() {
warn!("Warning: /etc/nginx/sites-available/ not found. Skipping NGINX config search.");
return;
}
let mut found_configs = false;
match fs::read_dir(&nginx_sites_available_path) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let config_content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => continue,
};
if config_content.contains(&format!("proxy_pass http://127.0.0.1:{}", port))
|| config_content.contains(&format!("listen {}", port))
{
info!("Found port {} in NGINX config: {}", port, path.display());
found_configs = true;
}
}
}
}
Err(e) => {
error!("Error reading NGINX sites-available directory: {}", e);
return;
}
}
if !found_configs {
info!("No NGINX configurations found for port {}.", port);
}
}
#[derive(Debug, Default, Clone)]
struct PortBindingInfo {
addresses: Vec<String>,
pids: Vec<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BindingScope {
NotListening,
LoopbackOnly,
ExternalOnly,
Mixed,
}
#[derive(Debug, Clone)]
struct BindingAssessment {
scope: BindingScope,
addresses: Vec<String>,
pids: Vec<u32>,
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FirewallPortVerdict {
Allowed,
Blocked,
Unknown,
}
#[derive(Debug, Clone)]
struct FirewallPortAssessment {
verdict: FirewallPortVerdict,
detail: String,
}
#[derive(Debug, Clone)]
struct FirewallContext {
backend: String,
active: bool,
note: Option<String>,
engine: FirewallEngine,
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
#[derive(Debug, Clone)]
enum FirewallEngine {
None,
Unsupported(String),
#[cfg(not(target_os = "windows"))]
Ufw(UfwSnapshot),
#[cfg(not(target_os = "windows"))]
Firewalld(FirewalldSnapshot),
#[cfg(not(target_os = "windows"))]
Nftables(NftablesSnapshot),
#[cfg(not(target_os = "windows"))]
Iptables(IptablesSnapshot),
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Clone)]
struct UfwRule {
port: u16,
protocol: Option<String>,
action_allow: bool,
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Clone)]
struct UfwSnapshot {
active: bool,
rules: Vec<UfwRule>,
status_line: String,
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Clone)]
struct FirewalldSnapshot {
active: bool,
open_ports: HashSet<String>,
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Clone)]
struct NftablesSnapshot {
active: bool,
ruleset: String,
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Clone)]
struct IptablesSnapshot {
active: bool,
rules: String,
input_policy: Option<String>,
}
async fn print_port_exposure_diagnostics(
requested_port: Option<u16>,
debug: bool,
) -> Result<(), String> {
let bindings = collect_tcp_listener_bindings(requested_port)?;
let mut ports: Vec<u16> = if let Some(port) = requested_port {
vec![port]
} else {
bindings.keys().copied().collect()
};
ports.sort_unstable();
ports.dedup();
if ports.is_empty() {
println!("\nPort Exposure Diagnostics");
println!("{:-<124}", "");
if let Some(port) = requested_port {
println!("No listening service found for requested port {}.", port);
} else {
println!("No listening TCP services found to diagnose.");
}
println!("{:-<124}", "");
return Ok(());
}
let firewall = detect_firewall_context(debug).await;
println!("\nPort Exposure Diagnostics");
println!("{:-<124}", "");
println!(
"Firewall backend: {} ({})",
firewall.backend.bright_white(),
if firewall.active {
"active".green()
} else {
"inactive/unknown".yellow()
}
);
if let Some(note) = &firewall.note {
println!("Firewall note: {}", note);
}
println!("{:-<124}", "");
println!(
"{:<8} {:<22} {:<18} {:<50} EXTERNAL",
"PORT", "SERVICE BIND", "FIREWALL", "DETAIL"
);
println!("{:-<124}", "");
for port in ports {
let binding = assess_binding(bindings.get(&port));
let firewall_assessment = firewall.evaluate_port(port);
let external = assess_external_reachability(&binding, &firewall_assessment);
println!(
"{:<8} {:<22} {:<18} {:<50} {}",
port,
binding_label(&binding),
firewall_verdict_label(&firewall_assessment.verdict),
truncate_for_table(&binding_firewall_detail(&binding, &firewall_assessment), 50),
external
);
}
println!("{:-<124}", "");
Ok(())
}
fn collect_tcp_listener_bindings(
port_filter: Option<u16>,
) -> Result<BTreeMap<u16, PortBindingInfo>, String> {
let af_flags: AddressFamilyFlags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags: ProtocolFlags = ProtocolFlags::TCP;
let sockets = get_sockets_info(af_flags, proto_flags)
.map_err(|e| format!("Failed to inspect listener bindings: {}", e))?;
let mut by_port: BTreeMap<u16, PortBindingInfo> = BTreeMap::new();
for socket in sockets {
if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
let state = format!("{:?}", tcp.state).to_ascii_uppercase();
if !state.contains("LISTEN") {
continue;
}
if let Some(filter) = port_filter {
if tcp.local_port != filter {
continue;
}
}
let entry = by_port.entry(tcp.local_port).or_default();
entry.addresses.push(tcp.local_addr.to_string());
entry.pids.extend(socket.associated_pids);
}
}
for info in by_port.values_mut() {
info.addresses.sort();
info.addresses.dedup();
info.pids.sort_unstable();
info.pids.dedup();
}
Ok(by_port)
}
fn assess_binding(info: Option<&PortBindingInfo>) -> BindingAssessment {
let Some(info) = info else {
return BindingAssessment {
scope: BindingScope::NotListening,
addresses: Vec::new(),
pids: Vec::new(),
};
};
let mut has_loopback = false;
let mut has_non_loopback = false;
for address in &info.addresses {
if let Ok(ip) = address.parse::<IpAddr>() {
if ip.is_loopback() {
has_loopback = true;
} else {
has_non_loopback = true;
}
} else {
has_non_loopback = true;
}
}
let scope = match (has_loopback, has_non_loopback) {
(false, false) => BindingScope::NotListening,
(true, false) => BindingScope::LoopbackOnly,
(false, true) => BindingScope::ExternalOnly,
(true, true) => BindingScope::Mixed,
};
BindingAssessment {
scope,
addresses: info.addresses.clone(),
pids: info.pids.clone(),
}
}
fn binding_label(binding: &BindingAssessment) -> colored::ColoredString {
match binding.scope {
BindingScope::NotListening => "not-listening".red(),
BindingScope::LoopbackOnly => "loopback-only".yellow(),
BindingScope::ExternalOnly => "external-bind".green(),
BindingScope::Mixed => "mixed-bind".bright_yellow(),
}
}
fn firewall_verdict_label(verdict: &FirewallPortVerdict) -> colored::ColoredString {
match verdict {
FirewallPortVerdict::Allowed => "allow".green(),
FirewallPortVerdict::Blocked => "block".red(),
FirewallPortVerdict::Unknown => "unknown".yellow(),
}
}
fn binding_firewall_detail(
binding: &BindingAssessment,
firewall_assessment: &FirewallPortAssessment,
) -> String {
let addresses = if binding.addresses.is_empty() {
"addr=-".to_string()
} else {
format!("addr={}", binding.addresses.join(","))
};
let pids = if binding.pids.is_empty() {
"pids=-".to_string()
} else {
format!(
"pids={}",
binding
.pids
.iter()
.map(|pid| pid.to_string())
.collect::<Vec<_>>()
.join(",")
)
};
format!("{}; {}; {}", addresses, pids, firewall_assessment.detail)
}
fn assess_external_reachability(
binding: &BindingAssessment,
firewall_assessment: &FirewallPortAssessment,
) -> colored::ColoredString {
if matches!(binding.scope, BindingScope::NotListening) {
return "blocked (no service)".red();
}
if matches!(binding.scope, BindingScope::LoopbackOnly) {
return "blocked (loopback bind)".red();
}
match firewall_assessment.verdict {
FirewallPortVerdict::Allowed => "reachable".green(),
FirewallPortVerdict::Blocked => "blocked (firewall)".red(),
FirewallPortVerdict::Unknown => "unknown".yellow(),
}
}
fn truncate_for_table(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>();
truncated.push_str("...");
truncated
}
impl FirewallContext {
fn evaluate_port(&self, _port: u16) -> FirewallPortAssessment {
match &self.engine {
FirewallEngine::None => FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "no firewall manager detected".to_string(),
},
FirewallEngine::Unsupported(reason) => FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: reason.clone(),
},
#[cfg(not(target_os = "windows"))]
FirewallEngine::Ufw(snapshot) => {
if !snapshot.active {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: format!("ufw inactive ({})", snapshot.status_line),
};
}
let mut has_allow = false;
let mut has_block = false;
for rule in &snapshot.rules {
if rule.port != _port {
continue;
}
if let Some(proto) = &rule.protocol {
if proto != "tcp" {
continue;
}
}
if rule.action_allow {
has_allow = true;
} else {
has_block = true;
}
}
if has_allow && !has_block {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "matched ufw allow rule".to_string(),
}
} else if has_block && !has_allow {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Blocked,
detail: "matched ufw deny/reject rule".to_string(),
}
} else if has_allow && has_block {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: "conflicting ufw allow/deny rules".to_string(),
}
} else {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: "no explicit ufw tcp rule for port".to_string(),
}
}
}
#[cfg(not(target_os = "windows"))]
FirewallEngine::Firewalld(snapshot) => {
if !snapshot.active {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "firewalld inactive".to_string(),
};
}
let token = format!("{}/tcp", _port);
if snapshot.open_ports.contains(&token) {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "present in firewalld --list-ports".to_string(),
}
} else {
FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: "not present in firewalld --list-ports".to_string(),
}
}
}
#[cfg(not(target_os = "windows"))]
FirewallEngine::Nftables(snapshot) => {
if !snapshot.active {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "nftables ruleset inactive/empty".to_string(),
};
}
match parse_nftables_port_verdict(&snapshot.ruleset, _port) {
Some(true) => FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "nftables has tcp dport accept rule".to_string(),
},
Some(false) => FirewallPortAssessment {
verdict: FirewallPortVerdict::Blocked,
detail: "nftables has tcp dport drop/reject rule".to_string(),
},
None => FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: "no explicit nftables verdict found".to_string(),
},
}
}
#[cfg(not(target_os = "windows"))]
FirewallEngine::Iptables(snapshot) => {
if !snapshot.active {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "iptables INPUT policy appears open".to_string(),
};
}
if let Some(verdict) = parse_iptables_port_verdict(&snapshot.rules, _port) {
if verdict {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Allowed,
detail: "iptables INPUT has ACCEPT rule".to_string(),
};
}
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Blocked,
detail: "iptables INPUT has DROP/REJECT rule".to_string(),
};
}
if let Some(policy) = &snapshot.input_policy {
if matches!(policy.as_str(), "DROP" | "REJECT") {
return FirewallPortAssessment {
verdict: FirewallPortVerdict::Blocked,
detail: format!("iptables INPUT default policy {}", policy),
};
}
}
FirewallPortAssessment {
verdict: FirewallPortVerdict::Unknown,
detail: "no explicit iptables match for port".to_string(),
}
}
}
}
}
#[cfg(target_os = "windows")]
async fn detect_firewall_context(_debug: bool) -> FirewallContext {
FirewallContext {
backend: "windows-firewall".to_string(),
active: false,
note: Some("Windows firewall diagnostics are not wired into `xbp ports` yet.".to_string()),
engine: FirewallEngine::Unsupported(
"firewall diagnostics currently implemented for Linux hosts".to_string(),
),
}
}
#[cfg(not(target_os = "windows"))]
async fn detect_firewall_context(debug: bool) -> FirewallContext {
let ufw = inspect_ufw(debug).await;
let firewalld = inspect_firewalld(debug).await;
let nftables = inspect_nftables(debug).await;
let iptables = inspect_iptables(debug).await;
if let Some(snapshot) = ufw.as_ref() {
if snapshot.active {
return FirewallContext {
backend: "ufw".to_string(),
active: true,
note: Some(snapshot.status_line.clone()),
engine: FirewallEngine::Ufw(snapshot.clone()),
};
}
}
if let Some(snapshot) = firewalld.as_ref() {
if snapshot.active {
return FirewallContext {
backend: "firewalld".to_string(),
active: true,
note: Some("derived from `firewall-cmd`".to_string()),
engine: FirewallEngine::Firewalld(snapshot.clone()),
};
}
}
if let Some(snapshot) = nftables.as_ref() {
if snapshot.active {
return FirewallContext {
backend: "nftables".to_string(),
active: true,
note: Some("derived from `nft list ruleset`".to_string()),
engine: FirewallEngine::Nftables(snapshot.clone()),
};
}
}
if let Some(snapshot) = iptables.as_ref() {
if snapshot.active {
return FirewallContext {
backend: "iptables".to_string(),
active: true,
note: Some("derived from `iptables -S INPUT`".to_string()),
engine: FirewallEngine::Iptables(snapshot.clone()),
};
}
}
if let Some(snapshot) = ufw {
return FirewallContext {
backend: "ufw".to_string(),
active: false,
note: Some(snapshot.status_line.clone()),
engine: FirewallEngine::Ufw(snapshot),
};
}
if let Some(snapshot) = firewalld {
return FirewallContext {
backend: "firewalld".to_string(),
active: false,
note: Some("firewalld installed but not running".to_string()),
engine: FirewallEngine::Firewalld(snapshot),
};
}
if let Some(snapshot) = nftables {
return FirewallContext {
backend: "nftables".to_string(),
active: false,
note: Some("nftables present, no active ruleset detected".to_string()),
engine: FirewallEngine::Nftables(snapshot),
};
}
if let Some(snapshot) = iptables {
return FirewallContext {
backend: "iptables".to_string(),
active: false,
note: Some("iptables present, no restrictive INPUT policy/rules detected".to_string()),
engine: FirewallEngine::Iptables(snapshot),
};
}
FirewallContext {
backend: "none-detected".to_string(),
active: false,
note: Some("no supported firewall manager found in PATH".to_string()),
engine: FirewallEngine::None,
}
}
#[cfg(not(target_os = "windows"))]
async fn inspect_ufw(debug: bool) -> Option<UfwSnapshot> {
if !command_exists("ufw") {
return None;
}
let output = run_command_with_optional_sudo("ufw", &["status"], debug).await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = format!("{}\n{}", stdout, stderr);
Some(parse_ufw_status(&combined))
}
#[cfg(not(target_os = "windows"))]
fn parse_ufw_status(content: &str) -> UfwSnapshot {
let mut status_line = "Status: unknown".to_string();
let mut active = false;
let mut rules = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let lowercase = trimmed.to_ascii_lowercase();
if lowercase.starts_with("status:") {
status_line = trimmed.to_string();
active = lowercase.contains("active");
continue;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let Some(port) = parse_leading_port(parts[0]) else {
continue;
};
let protocol = parts[0]
.split('/')
.nth(1)
.map(|value| value.to_ascii_lowercase());
let action = parts[1].to_ascii_uppercase();
if action.starts_with("ALLOW") {
rules.push(UfwRule {
port,
protocol,
action_allow: true,
});
} else if action.starts_with("DENY") || action.starts_with("REJECT") {
rules.push(UfwRule {
port,
protocol,
action_allow: false,
});
}
}
UfwSnapshot {
active,
rules,
status_line,
}
}
#[cfg(not(target_os = "windows"))]
async fn inspect_firewalld(debug: bool) -> Option<FirewalldSnapshot> {
if !command_exists("firewall-cmd") {
return None;
}
let state_output = run_command_with_optional_sudo("firewall-cmd", &["--state"], debug).await?;
let state_stdout = String::from_utf8_lossy(&state_output.stdout).to_ascii_lowercase();
let active = state_output.status.success() && state_stdout.contains("running");
let mut open_ports = HashSet::new();
if active {
if let Some(list_output) =
run_command_with_optional_sudo("firewall-cmd", &["--list-ports"], debug).await
{
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
for token in list_stdout.split_whitespace() {
open_ports.insert(token.trim().to_ascii_lowercase());
}
}
}
Some(FirewalldSnapshot { active, open_ports })
}
#[cfg(not(target_os = "windows"))]
async fn inspect_nftables(debug: bool) -> Option<NftablesSnapshot> {
if !command_exists("nft") {
return None;
}
let output = run_command_with_optional_sudo("nft", &["list", "ruleset"], debug).await?;
let ruleset = String::from_utf8_lossy(&output.stdout).to_string();
let active = output.status.success() && ruleset.contains("table ");
Some(NftablesSnapshot { active, ruleset })
}
#[cfg(not(target_os = "windows"))]
fn parse_nftables_port_verdict(ruleset: &str, port: u16) -> Option<bool> {
let mut allow = false;
let mut block = false;
for line in ruleset.lines() {
let lower = line.to_ascii_lowercase();
if !lower.contains("dport") || !contains_port_number(&lower, port) {
continue;
}
if lower.contains("drop") || lower.contains("reject") {
block = true;
}
if lower.contains("accept") {
allow = true;
}
}
if block {
Some(false)
} else if allow {
Some(true)
} else {
None
}
}
#[cfg(not(target_os = "windows"))]
async fn inspect_iptables(debug: bool) -> Option<IptablesSnapshot> {
if !command_exists("iptables") {
return None;
}
let output = run_command_with_optional_sudo("iptables", &["-S", "INPUT"], debug).await?;
let rules = String::from_utf8_lossy(&output.stdout).to_string();
let policy = parse_iptables_input_policy(&rules);
let has_rule = rules
.lines()
.any(|line| line.trim_start().starts_with("-A INPUT"));
let active = has_rule
|| matches!(
policy.as_deref(),
Some("DROP") | Some("REJECT") | Some("QUEUE")
);
Some(IptablesSnapshot {
active,
rules,
input_policy: policy,
})
}
#[cfg(not(target_os = "windows"))]
fn parse_iptables_input_policy(rules: &str) -> Option<String> {
for line in rules.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("-P INPUT ") {
continue;
}
return trimmed
.split_whitespace()
.nth(2)
.map(|value| value.to_string());
}
None
}
#[cfg(not(target_os = "windows"))]
fn parse_iptables_port_verdict(rules: &str, port: u16) -> Option<bool> {
let mut allow = false;
let mut block = false;
for line in rules.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("-A INPUT ") {
continue;
}
if !trimmed.contains("-p tcp") {
continue;
}
if !line_matches_iptables_port(trimmed, port) {
continue;
}
if trimmed.contains("-j ACCEPT") {
allow = true;
} else if trimmed.contains("-j DROP") || trimmed.contains("-j REJECT") {
block = true;
}
}
if block {
Some(false)
} else if allow {
Some(true)
} else {
None
}
}
#[cfg(not(target_os = "windows"))]
fn line_matches_iptables_port(line: &str, port: u16) -> bool {
let exact = format!("--dport {}", port);
if line.contains(&exact) {
return true;
}
let dports_pos = line.find("--dports ");
if let Some(index) = dports_pos {
let remaining = &line[index + "--dports ".len()..];
let token = remaining.split_whitespace().next().unwrap_or("");
return token
.split(',')
.map(|value| value.trim())
.any(|value| value == port.to_string());
}
false
}
#[cfg(not(target_os = "windows"))]
fn contains_port_number(line: &str, port: u16) -> bool {
let needle = port.to_string();
line.split(|ch: char| !ch.is_ascii_digit())
.any(|part| part == needle)
}
#[cfg(not(target_os = "windows"))]
fn parse_leading_port(token: &str) -> Option<u16> {
let digits: String = token.chars().take_while(|ch| ch.is_ascii_digit()).collect();
if digits.is_empty() {
None
} else {
digits.parse::<u16>().ok()
}
}
#[cfg(not(target_os = "windows"))]
async fn run_command_with_optional_sudo(
program: &str,
args: &[&str],
debug: bool,
) -> Option<Output> {
let output = Command::new(program).args(args).output().await.ok()?;
if output.status.success() {
return Some(output);
}
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
let needs_sudo = stderr.contains("permission denied")
|| stderr.contains("must be root")
|| stderr.contains("not permitted")
|| stdout.contains("permission denied")
|| stdout.contains("must be root");
if !needs_sudo || !command_exists("sudo") {
return Some(output);
}
if debug {
debug!(
"Retrying command with non-interactive sudo: {} {:?}",
program, args
);
}
let mut sudo_args = vec!["-n", program];
sudo_args.extend(args.iter().copied());
Command::new("sudo").args(sudo_args).output().await.ok()
}
async fn print_reconciled_ports(port_filter: Option<&str>) -> Result<(), String> {
let active_ports = collect_listening_port_ownership()?;
let nginx_sites = inspect_nginx_configs(false).map_err(|e| e.to_string())?;
let xbp_ports = collect_xbp_project_ports();
let mut rows: BTreeMap<u16, PortRow> = BTreeMap::new();
for (port, active) in active_ports {
let row = rows.entry(port).or_default();
row.active = true;
row.pids
.extend(active.pids.into_iter().map(|pid| pid.to_string()));
row.projects.extend(active.xbp_projects);
}
for site in nginx_sites {
for port in site.upstream_ports {
let row = rows.entry(port).or_default();
let listens = if site.listen_ports.is_empty() {
"-".to_string()
} else {
site.listen_ports
.iter()
.map(|port| port.to_string())
.collect::<Vec<_>>()
.join(",")
};
row.nginx
.push(format!("{} (listen {})", site.domain, listens));
}
}
for (port, projects) in xbp_ports {
rows.entry(port).or_default().projects.extend(projects);
}
println!("\nReconciled Ports");
println!("{:-<110}", "");
println!(
"{:<8} {:<8} {:<18} {:<34} XBP PROJECTS",
"PORT", "ACTIVE", "PIDS", "NGINX"
);
println!("{:-<110}", "");
let requested = port_filter.and_then(|port| port.parse::<u16>().ok());
let mut xbp_rows = Vec::new();
let mut other_rows = Vec::new();
for (port, row) in rows {
if requested.is_some() && requested != Some(port) {
continue;
}
if row.is_xbp() {
xbp_rows.push((port, row));
} else {
other_rows.push((port, row));
}
}
for (port, row) in xbp_rows.into_iter().chain(other_rows.into_iter()) {
let line = format!(
"{:<8} {:<8} {:<18} {:<34} {}",
port,
if row.active { "yes" } else { "no" },
join_strings(&row.pids),
join_strings(&row.nginx),
join_strings(&row.projects),
);
if row.is_xbp() {
println!("{}", line.bright_magenta());
} else {
println!("{}", line);
}
}
println!("{:-<110}", "");
Ok(())
}
fn collect_xbp_project_ports() -> BTreeMap<u16, Vec<String>> {
let mut by_port: BTreeMap<u16, Vec<String>> = BTreeMap::new();
for project in collect_known_xbp_projects() {
let Some(found) = find_xbp_config_upwards(&project.root) else {
continue;
};
let Ok(content) = fs::read_to_string(&found.config_path) else {
continue;
};
let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind) else {
continue;
};
by_port
.entry(config.port)
.or_default()
.push(project.name.clone());
if let Some(services) = config.services {
for service in services {
by_port
.entry(service.port)
.or_default()
.push(format!("{}/{}", project.name, service.name));
}
}
}
for values in by_port.values_mut() {
values.sort();
values.dedup();
}
by_port
}
fn join_strings(values: &[String]) -> String {
if values.is_empty() {
"-".to_string()
} else {
let mut deduped = values.to_vec();
deduped.sort();
deduped.dedup();
deduped.join(", ")
}
}
#[derive(Debug, Default)]
struct PortRow {
active: bool,
pids: Vec<String>,
nginx: Vec<String>,
projects: Vec<String>,
}
impl PortRow {
fn is_xbp(&self) -> bool {
!self.projects.is_empty()
}
}