use serde::Serialize;
use std::fmt;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::{Duration, Instant};
pub const TICK_RATE: Duration = Duration::from_secs(2);
pub const GONE_RETENTION: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub enum Protocol {
Tcp,
Udp,
}
impl fmt::Display for Protocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Protocol::Tcp => write!(f, "TCP"),
Protocol::Udp => write!(f, "UDP"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub enum ConnectionState {
Listen,
Established,
TimeWait,
CloseWait,
SynSent,
SynRecv,
FinWait1,
FinWait2,
Closing,
LastAck,
Closed,
Unknown,
}
impl fmt::Display for ConnectionState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ConnectionState::Listen => "LISTEN",
ConnectionState::Established => "ESTABLISHED",
ConnectionState::TimeWait => "TIME_WAIT",
ConnectionState::CloseWait => "CLOSE_WAIT",
ConnectionState::SynSent => "SYN_SENT",
ConnectionState::SynRecv => "SYN_RECV",
ConnectionState::FinWait1 => "FIN_WAIT1",
ConnectionState::FinWait2 => "FIN_WAIT2",
ConnectionState::Closing => "CLOSING",
ConnectionState::LastAck => "LAST_ACK",
ConnectionState::Closed => "CLOSED",
ConnectionState::Unknown => "UNKNOWN",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
pub path: Option<PathBuf>,
pub cmdline: Option<String>,
pub user: Option<String>,
pub parent_pid: Option<u32>,
pub parent_name: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortEntry {
pub protocol: Protocol,
pub local_addr: SocketAddr,
pub remote_addr: Option<SocketAddr>,
pub state: ConnectionState,
pub process: ProcessInfo,
}
impl PortEntry {
pub fn local_port(&self) -> u16 {
self.local_addr.port()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryStatus {
Unchanged,
New,
Gone,
}
#[derive(Debug, Clone)]
pub struct TrackedEntry {
pub entry: PortEntry,
pub status: EntryStatus,
pub seen_at: Instant,
pub first_seen: Option<Instant>,
pub suspicious: Vec<SuspiciousReason>,
pub container_name: Option<String>,
pub service_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SuspiciousReason {
NonRootPrivileged,
ScriptOnSensitive,
RootHighPortOutgoing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Port,
Service,
Protocol,
State,
Pid,
ProcessName,
User,
}
#[derive(Debug, Clone, Copy)]
pub struct SortState {
pub column: SortColumn,
pub ascending: bool,
}
impl Default for SortState {
fn default() -> Self {
Self {
column: SortColumn::Port,
ascending: true,
}
}
}
impl SortState {
pub fn toggle(&mut self, col: SortColumn) {
if self.column == col {
self.ascending = !self.ascending;
} else {
self.column = col;
self.ascending = true;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
#[default]
Connections,
Processes,
Ssh,
}
impl ViewMode {
pub const ALL: &[ViewMode] = &[ViewMode::Connections, ViewMode::Processes, ViewMode::Ssh];
fn index(self) -> usize {
Self::ALL
.iter()
.position(|&m| m == self)
.expect("all ViewMode variants must be listed in ALL")
}
pub fn next(self) -> Self {
Self::ALL[(self.index() + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcessesTab {
#[default]
Detail,
Topology,
}
impl ProcessesTab {
pub const ALL: &[ProcessesTab] = &[ProcessesTab::Detail, ProcessesTab::Topology];
pub fn next(self) -> Self {
match self {
ProcessesTab::Detail => ProcessesTab::Topology,
ProcessesTab::Topology => ProcessesTab::Detail,
}
}
pub fn prev(self) -> Self {
self.next()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionItem {
Kill,
Copy,
CopyPid,
BlockIp,
Trace,
Forward,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SshTab {
#[default]
Hosts,
Tunnels,
}
impl SshTab {
pub const ALL: &[SshTab] = &[SshTab::Hosts, SshTab::Tunnels];
pub fn next(self) -> Self {
match self {
SshTab::Hosts => SshTab::Tunnels,
SshTab::Tunnels => SshTab::Hosts,
}
}
pub fn prev(self) -> Self {
self.next()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Json,
Csv,
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn make_process() -> ProcessInfo {
ProcessInfo {
pid: 1,
name: "test".into(),
path: None,
cmdline: None,
user: None,
parent_pid: None,
parent_name: None,
}
}
#[test]
fn local_port_returns_port_from_addr() {
let entry = PortEntry {
protocol: Protocol::Tcp,
local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
remote_addr: None,
state: ConnectionState::Listen,
process: make_process(),
};
assert_eq!(entry.local_port(), 8080);
}
#[test]
fn sort_state_default_is_port_ascending() {
let s = SortState::default();
assert_eq!(s.column, SortColumn::Port);
assert!(s.ascending);
}
#[test]
fn sort_state_toggle_same_column_flips_direction() {
let mut s = SortState::default();
s.toggle(SortColumn::Port);
assert!(!s.ascending);
s.toggle(SortColumn::Port);
assert!(s.ascending);
}
#[test]
fn sort_state_toggle_different_column_resets_ascending() {
let mut s = SortState::default();
s.toggle(SortColumn::Port);
s.toggle(SortColumn::Pid);
assert_eq!(s.column, SortColumn::Pid);
assert!(s.ascending);
}
#[test]
fn protocol_display() {
assert_eq!(Protocol::Tcp.to_string(), "TCP");
assert_eq!(Protocol::Udp.to_string(), "UDP");
}
#[test]
fn connection_state_display() {
let cases = [
(ConnectionState::Listen, "LISTEN"),
(ConnectionState::Established, "ESTABLISHED"),
(ConnectionState::TimeWait, "TIME_WAIT"),
(ConnectionState::CloseWait, "CLOSE_WAIT"),
(ConnectionState::SynSent, "SYN_SENT"),
(ConnectionState::SynRecv, "SYN_RECV"),
(ConnectionState::FinWait1, "FIN_WAIT1"),
(ConnectionState::FinWait2, "FIN_WAIT2"),
(ConnectionState::Closing, "CLOSING"),
(ConnectionState::LastAck, "LAST_ACK"),
(ConnectionState::Closed, "CLOSED"),
(ConnectionState::Unknown, "UNKNOWN"),
];
for (state, expected) in cases {
assert_eq!(state.to_string(), expected, "state {:?}", state);
}
}
#[test]
fn view_mode_default_is_connections() {
assert_eq!(ViewMode::default(), ViewMode::Connections);
}
#[test]
fn view_mode_next_prev_cycle() {
let cases = [
(ViewMode::Connections, ViewMode::Processes),
(ViewMode::Processes, ViewMode::Ssh),
(ViewMode::Ssh, ViewMode::Connections),
];
for (from, expected) in cases {
assert_eq!(from.next(), expected);
assert_eq!(expected.prev(), from);
}
}
#[test]
fn processes_tab_cycle() {
assert_eq!(ProcessesTab::Detail.next(), ProcessesTab::Topology);
assert_eq!(ProcessesTab::Topology.next(), ProcessesTab::Detail);
assert_eq!(ProcessesTab::default(), ProcessesTab::Detail);
}
#[test]
fn ssh_tab_cycle() {
assert_eq!(SshTab::Hosts.next(), SshTab::Tunnels);
assert_eq!(SshTab::Tunnels.next(), SshTab::Hosts);
assert_eq!(SshTab::default(), SshTab::Hosts);
}
#[test]
fn sort_state_toggle_all_columns() {
let columns = [
SortColumn::Port,
SortColumn::Service,
SortColumn::Protocol,
SortColumn::State,
SortColumn::Pid,
SortColumn::ProcessName,
SortColumn::User,
];
for col in columns {
let mut s = SortState::default();
s.toggle(col);
if col == SortColumn::Port {
assert!(!s.ascending, "toggling same column should flip");
} else {
assert_eq!(s.column, col);
assert!(s.ascending, "switching to {:?} should be ascending", col);
}
}
}
}