mod capture;
mod config;
mod data;
mod ui;
mod util;
use std::io;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton, EnableMouseCapture, DisableMouseCapture};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;
use config::cli::Args;
use data::tracker::FlowTracker;
use ui::app::{AppState, CliOverrides, SortColumn, ViewTab};
use util::resolver::Resolver;
fn main() -> Result<()> {
let args = Args::parse();
if args.help {
config::cli::print_cyberpunk_help();
return Ok(());
}
if args.version {
println!("iftoprs {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if let Some(shell) = args.completions {
Args::generate_completions(shell);
return Ok(());
}
if args.list_colors {
Args::print_colors();
return Ok(());
}
if args.list_interfaces {
let interfaces = capture::sniffer::list_interfaces()?;
println!("Available interfaces:");
for iface in interfaces {
println!(" {}", iface);
}
return Ok(());
}
if args.json {
return run_json_mode(&args);
}
if let Some(ref path) = args.config {
config::prefs::set_config_path(std::path::PathBuf::from(path));
}
let prefs = config::prefs::load_prefs();
let effective_interface = args.interface.clone().or(prefs.interface.clone());
let local_net = args.parse_net_filter().or_else(|| {
auto_detect_local_net(effective_interface.as_deref())
});
let resolver = Resolver::new(!args.no_dns);
let tracker = FlowTracker::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let _capture_handle = capture::sniffer::start_capture(
effective_interface.clone(),
args.filter.clone(),
args.promiscuous,
local_net,
tx,
)?;
let tracker_proc = tracker.clone();
std::thread::Builder::new()
.name("proc-lookup".into())
.spawn(move || {
loop {
util::procinfo::refresh_proc_table();
std::thread::sleep(Duration::from_secs(2));
let keys = tracker_proc.flow_keys();
for key in keys {
if let Some((pid, name)) = util::lookup_process(
key.src,
key.src_port,
key.dst,
key.dst_port,
&key.protocol,
) {
tracker_proc.set_process_info(&key, pid, name);
}
}
}
})
.context("Failed to spawn proc-lookup thread")?;
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).context("Failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Failed to create terminal")?;
let cli_overrides = CliOverrides {
dns: args.no_dns,
show_ports: args.hide_ports,
show_bars: args.no_bars,
use_bytes: args.bytes,
show_processes: args.no_processes,
interface: args.interface.is_some(),
};
let mut app = AppState::new(
resolver,
!args.hide_ports,
!args.no_bars,
args.bytes,
!args.no_processes,
&prefs,
cli_overrides,
);
app.interface_name = effective_interface.clone().unwrap_or_default();
if args.interface.is_some() {
app.config_interface = args.interface.clone();
}
let result = run_app(&mut terminal, &mut app, &tracker, &mut rx);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), DisableMouseCapture, LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut AppState,
tracker: &FlowTracker,
rx: &mut mpsc::UnboundedReceiver<capture::sniffer::PacketEvent>,
) -> Result<()> {
let tick_rate = Duration::from_millis(33); let mut last_tick = Instant::now();
let mut last_snapshot = Instant::now();
loop {
while let Ok(event) = rx.try_recv() {
tracker.record(
event.parsed.key,
event.parsed.direction,
event.parsed.len,
);
}
tracker.maybe_rotate();
let refresh_interval = Duration::from_secs(app.refresh_rate);
if last_snapshot.elapsed() >= refresh_interval {
let (flows, totals) = tracker.snapshot();
app.update_snapshot(flows, totals);
last_snapshot = Instant::now();
}
terminal.draw(|frame| {
ui::render::draw(frame, app);
})?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::ZERO);
if event::poll(timeout).context("Failed to poll events")? {
let ev = event::read().context("Failed to read event")?;
if let Event::Mouse(mouse) = ev {
handle_mouse(app, mouse);
continue;
}
let Event::Key(key) = ev else { continue; };
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')
{
return Ok(());
}
if app.filter_state.active {
match key.code {
KeyCode::Enter => {
app.filter_state.active = false;
let f = app.filter_state.buf.clone();
app.screen_filter = if f.is_empty() { None } else { Some(f) };
}
KeyCode::Esc => {
app.filter_state.active = false;
app.screen_filter = app.filter_state.prev.clone();
}
KeyCode::Backspace => app.filter_state.backspace(),
KeyCode::Left => app.filter_state.left(),
KeyCode::Right => app.filter_state.right(),
KeyCode::Home => app.filter_state.home(),
KeyCode::End => app.filter_state.end(),
KeyCode::Char(ch) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match ch {
'w' => app.filter_state.delete_word(),
'a' => app.filter_state.home(),
'e' => app.filter_state.end(),
'k' => app.filter_state.kill_to_end(),
'u' => { app.filter_state.buf.clear(); app.filter_state.cursor = 0; }
_ => {}
}
} else {
app.filter_state.insert(ch);
}
let f = app.filter_state.buf.clone();
app.screen_filter = if f.is_empty() { None } else { Some(f) };
}
_ => {}
}
continue;
}
if app.theme_chooser.active {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
let len = config::theme::ThemeName::ALL.len();
app.theme_chooser.selected = (app.theme_chooser.selected + 1) % len;
let name = config::theme::ThemeName::ALL[app.theme_chooser.selected];
app.set_theme(name);
}
KeyCode::Char('k') | KeyCode::Up => {
let len = config::theme::ThemeName::ALL.len();
app.theme_chooser.selected = (app.theme_chooser.selected + len - 1) % len;
let name = config::theme::ThemeName::ALL[app.theme_chooser.selected];
app.set_theme(name);
}
KeyCode::Enter => {
app.theme_chooser.active = false;
app.save_prefs();
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => {
app.theme_chooser.active = false;
}
_ => {}
}
continue;
}
if app.theme_edit.active {
if app.theme_edit.naming {
match key.code {
KeyCode::Enter => {
let name = app.theme_edit.name.trim().to_string();
if !name.is_empty() {
let c = app.theme_edit.colors;
app.custom_themes.insert(name.clone(), config::theme::CustomThemeColors {
c1: c[0], c2: c[1], c3: c[2], c4: c[3], c5: c[4], c6: c[5],
});
app.active_custom_theme = Some(name.clone());
app.save_prefs();
app.set_status(format!("Saved theme: {}", name));
}
app.theme_edit.active = false;
app.theme_edit.naming = false;
app.theme_edit.name.clear();
app.theme_edit.cursor = 0;
}
KeyCode::Esc => {
app.theme_edit.naming = false;
app.theme_edit.name.clear();
app.theme_edit.cursor = 0;
}
KeyCode::Backspace => {
if app.theme_edit.cursor > 0 {
app.theme_edit.cursor -= 1;
app.theme_edit.name.remove(app.theme_edit.cursor);
}
}
KeyCode::Left => {
app.theme_edit.cursor = app.theme_edit.cursor.saturating_sub(1);
}
KeyCode::Right => {
app.theme_edit.cursor = (app.theme_edit.cursor + 1).min(app.theme_edit.name.len());
}
KeyCode::Char(c) => {
if app.theme_edit.name.len() < 20 {
app.theme_edit.name.insert(app.theme_edit.cursor, c);
app.theme_edit.cursor += 1;
}
}
_ => {}
}
} else {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.theme_edit.active = false;
if let Some(ref name) = app.active_custom_theme
&& let Some(ct) = app.custom_themes.get(name) {
app.apply_custom_palette([ct.c1, ct.c2, ct.c3, ct.c4, ct.c5, ct.c6]);
} else {
app.set_theme(app.theme_name);
}
}
KeyCode::Char('j') | KeyCode::Down => {
app.theme_edit.slot = (app.theme_edit.slot + 1).min(5);
}
KeyCode::Char('k') | KeyCode::Up => {
app.theme_edit.slot = app.theme_edit.slot.saturating_sub(1);
}
KeyCode::Char('l') | KeyCode::Right => {
app.theme_edit.colors[app.theme_edit.slot] =
app.theme_edit.colors[app.theme_edit.slot].wrapping_add(1);
app.apply_custom_palette(app.theme_edit.colors);
}
KeyCode::Char('h') | KeyCode::Left => {
app.theme_edit.colors[app.theme_edit.slot] =
app.theme_edit.colors[app.theme_edit.slot].wrapping_sub(1);
app.apply_custom_palette(app.theme_edit.colors);
}
KeyCode::Char('L') => {
app.theme_edit.colors[app.theme_edit.slot] =
app.theme_edit.colors[app.theme_edit.slot].wrapping_add(10);
app.apply_custom_palette(app.theme_edit.colors);
}
KeyCode::Char('H') => {
app.theme_edit.colors[app.theme_edit.slot] =
app.theme_edit.colors[app.theme_edit.slot].wrapping_sub(10);
app.apply_custom_palette(app.theme_edit.colors);
}
KeyCode::Enter | KeyCode::Char('s') | KeyCode::Char('S') => {
app.theme_edit.naming = true;
app.theme_edit.name.clear();
app.theme_edit.cursor = 0;
}
_ => {}
}
}
continue;
}
if app.interface_chooser.active {
match key.code {
KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('i') => {
let len = app.interface_chooser.interfaces.len();
if len > 0 {
app.interface_chooser.selected = (app.interface_chooser.selected + 1) % len;
}
}
KeyCode::Char('k') | KeyCode::Up => {
let len = app.interface_chooser.interfaces.len();
if len > 0 {
app.interface_chooser.selected = (app.interface_chooser.selected + len - 1) % len;
}
}
KeyCode::Enter => {
let name = app.interface_chooser.interfaces[app.interface_chooser.selected].clone();
app.interface_chooser.active = false;
app.interface_name = name.clone();
app.config_interface = Some(name.clone());
app.save_prefs();
app.set_status(format!("Interface: {} (restart to apply)", name));
}
KeyCode::Esc | KeyCode::Char('q') => {
app.interface_chooser.active = false;
}
_ => {}
}
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('d') => match app.view_tab {
ViewTab::Flows => app.page_down(),
ViewTab::Processes => app.process_page_down(),
},
KeyCode::Char('u') => match app.view_tab {
ViewTab::Flows => app.page_up(),
ViewTab::Processes => app.process_page_up(),
},
_ => {}
}
continue;
}
match key.code {
KeyCode::Tab | KeyCode::BackTab => {
app.view_tab = match app.view_tab {
ViewTab::Flows => ViewTab::Processes,
ViewTab::Processes => ViewTab::Flows,
};
app.set_status(match app.view_tab {
ViewTab::Flows => "View: Flows",
ViewTab::Processes => "View: Processes",
});
}
KeyCode::Char('q') => { app.save_prefs(); return Ok(()); }
KeyCode::Char('h') | KeyCode::Char('?') => app.show_help = !app.show_help,
KeyCode::Char('c') => {
app.show_help = false;
app.theme_chooser.open(app.theme_name);
}
KeyCode::Char('C') => {
app.show_help = false;
let palette = if let Some(ref name) = app.active_custom_theme
&& let Some(ct) = app.custom_themes.get(name) {
[ct.c1, ct.c2, ct.c3, ct.c4, ct.c5, ct.c6]
} else {
config::theme::Theme::palette_values(app.theme_name)
};
app.theme_edit.open(palette);
}
KeyCode::Char('i') => {
app.show_help = false;
app.interface_chooser.open(&app.interface_name);
}
KeyCode::Char('/') => {
app.show_help = false;
app.filter_state.open(&app.screen_filter);
}
KeyCode::Char('0') => {
app.screen_filter = None;
app.set_status("Filter cleared");
}
KeyCode::Char('e') => app.export(),
KeyCode::Char('y') => app.copy_selected(),
KeyCode::Char('F') => app.toggle_pin(),
KeyCode::Char('n') => {
app.resolver.toggle();
app.show_dns = app.resolver.is_enabled();
app.save_prefs();
}
KeyCode::Char('N') => { app.show_port_names = !app.show_port_names; app.save_prefs(); }
KeyCode::Char('p') => { app.show_ports = !app.show_ports; app.save_prefs(); }
KeyCode::Char('b') => {
app.bar_style = app.bar_style.next();
app.set_status(format!("Bar style: {}", app.bar_style.name()));
app.save_prefs();
}
KeyCode::Char('B') => { app.use_bytes = !app.use_bytes; app.save_prefs(); }
KeyCode::Char('t') => { app.line_display = app.line_display.next(); app.save_prefs(); }
KeyCode::Char('T') => { app.show_cumulative = !app.show_cumulative; app.save_prefs(); }
KeyCode::Char('Z') => { app.show_processes = !app.show_processes; app.save_prefs(); }
KeyCode::Char('P') => app.paused = !app.paused,
KeyCode::Char('x') => {
app.show_border = !app.show_border;
app.set_status(if app.show_border { "Border: on" } else { "Border: off" });
app.save_prefs();
}
KeyCode::Char('g') => {
app.show_header = !app.show_header;
app.set_status(if app.show_header { "Header: on" } else { "Header: off" });
app.save_prefs();
}
KeyCode::Char('f') => app.cycle_refresh_rate(),
KeyCode::Char('1') => { app.sort_column = SortColumn::Avg2s; app.frozen_order = false; }
KeyCode::Char('2') => { app.sort_column = SortColumn::Avg10s; app.frozen_order = false; }
KeyCode::Char('3') => { app.sort_column = SortColumn::Avg40s; app.frozen_order = false; }
KeyCode::Char('<') => { app.sort_column = SortColumn::SrcName; app.frozen_order = false; }
KeyCode::Char('>') => { app.sort_column = SortColumn::DstName; app.frozen_order = false; }
KeyCode::Char('r') => {
app.sort_reverse = !app.sort_reverse;
app.set_status(if app.sort_reverse { "Sort: reversed" } else { "Sort: normal" });
}
KeyCode::Char('o') => app.frozen_order = !app.frozen_order,
KeyCode::Char('j') | KeyCode::Down => {
match app.view_tab {
ViewTab::Flows => app.select_next(),
ViewTab::Processes => app.process_select_next(),
}
}
KeyCode::Char('k') | KeyCode::Up => {
match app.view_tab {
ViewTab::Flows => app.select_prev(),
ViewTab::Processes => app.process_select_prev(),
}
}
KeyCode::Char('G') | KeyCode::End => {
match app.view_tab {
ViewTab::Flows => app.jump_bottom(),
ViewTab::Processes => {
let last = app.process_snapshots.len().saturating_sub(1);
app.process_selected = Some(last);
app.process_scroll = last.saturating_sub(19);
}
}
}
KeyCode::Home => {
match app.view_tab {
ViewTab::Flows => app.jump_top(),
ViewTab::Processes => { app.process_selected = Some(0); app.process_scroll = 0; }
}
}
KeyCode::Enter => {
if matches!(app.view_tab, ViewTab::Processes) {
app.process_drill_down();
}
}
KeyCode::Esc => {
match app.view_tab {
ViewTab::Flows => {
if app.process_filter.is_some() {
app.clear_process_filter();
} else {
app.selected = None;
app.show_help = false;
}
}
ViewTab::Processes => { app.process_selected = None; app.show_help = false; }
}
}
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}
fn handle_mouse(app: &mut AppState, mouse: MouseEvent) {
if matches!(mouse.kind, MouseEventKind::Down(_)) {
app.tooltip.active = false;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
let row = mouse.row;
if row >= app.flow_area_y {
match app.view_tab {
ViewTab::Flows => {
let idx = app.scroll_offset + (row - app.flow_area_y) as usize;
if idx < app.flows.len() {
app.selected = Some(idx);
}
}
ViewTab::Processes => {
let idx = app.process_scroll + (row - app.flow_area_y).saturating_sub(1) as usize;
if idx < app.process_snapshots.len() {
app.process_selected = Some(idx);
}
}
}
}
}
MouseEventKind::Down(MouseButton::Right) => {
let row = mouse.row;
if app.show_header && row == app.header_bar_y {
app.hover.right_click_at(mouse.column, mouse.row);
} else if row >= app.flow_area_y {
match app.view_tab {
ViewTab::Flows => {
let idx = app.scroll_offset + (row - app.flow_area_y) as usize;
if idx < app.flows.len() {
app.selected = Some(idx);
app.show_tooltip(idx, mouse.column, mouse.row);
}
}
ViewTab::Processes => {
let idx = app.process_scroll + (row - app.flow_area_y).saturating_sub(1) as usize;
if idx < app.process_snapshots.len() {
app.process_selected = Some(idx);
}
}
}
}
}
MouseEventKind::ScrollDown => match app.view_tab {
ViewTab::Flows => app.select_next(),
ViewTab::Processes => app.process_select_next(),
},
MouseEventKind::ScrollUp => match app.view_tab {
ViewTab::Flows => app.select_prev(),
ViewTab::Processes => app.process_select_prev(),
},
MouseEventKind::Down(MouseButton::Middle) => {
if matches!(app.view_tab, ViewTab::Flows) {
let row = mouse.row;
if row >= app.flow_area_y {
let idx = app.scroll_offset + (row - app.flow_area_y) as usize;
if idx < app.flows.len() {
app.selected = Some(idx);
app.toggle_pin();
}
}
}
}
MouseEventKind::Moved => {
app.hover.move_to(mouse.column, mouse.row);
}
_ => {}
}
}
fn run_json_mode(args: &Args) -> Result<()> {
use serde::Serialize;
if let Some(ref path) = args.config {
config::prefs::set_config_path(std::path::PathBuf::from(path));
}
let prefs = config::prefs::load_prefs();
let effective_interface = args.interface.clone().or(prefs.interface.clone());
let local_net = args.parse_net_filter().or_else(|| {
auto_detect_local_net(effective_interface.as_deref())
});
let resolver = Resolver::new(!args.no_dns);
let tracker = FlowTracker::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let _capture_handle = capture::sniffer::start_capture(
effective_interface,
args.filter.clone(),
args.promiscuous,
local_net,
tx,
)?;
let tracker_proc = tracker.clone();
std::thread::Builder::new()
.name("proc-lookup".into())
.spawn(move || {
loop {
util::procinfo::refresh_proc_table();
std::thread::sleep(Duration::from_secs(2));
let keys = tracker_proc.flow_keys();
for key in keys {
if let Some((pid, name)) = util::lookup_process(
key.src, key.src_port, key.dst, key.dst_port, &key.protocol,
) {
tracker_proc.set_process_info(&key, pid, name);
}
}
}
})
.context("Failed to spawn proc-lookup thread")?;
let refresh = Duration::from_secs(prefs.refresh_rate);
let use_bytes = args.bytes;
#[derive(Serialize)]
struct JsonFlow {
src: String,
dst: String,
src_port: u16,
dst_port: u16,
protocol: String,
sent_2s: f64,
recv_2s: f64,
sent_10s: f64,
recv_10s: f64,
sent_40s: f64,
recv_40s: f64,
total_sent: u64,
total_recv: u64,
#[serde(skip_serializing_if = "Option::is_none")]
process_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pid: Option<u32>,
}
#[derive(Serialize)]
struct JsonSnapshot {
timestamp: String,
flow_count: usize,
total_sent: u64,
total_recv: u64,
peak_sent: f64,
peak_recv: f64,
flows: Vec<JsonFlow>,
}
loop {
while let Ok(event) = rx.try_recv() {
tracker.record(event.parsed.key, event.parsed.direction, event.parsed.len);
}
tracker.maybe_rotate();
let (flows, totals) = tracker.snapshot();
let json_flows: Vec<JsonFlow> = flows.iter().map(|f| {
let src_host = resolver.resolve(f.key.src);
let dst_host = resolver.resolve(f.key.dst);
JsonFlow {
src: if f.key.src_port > 0 { format!("{}:{}", src_host, f.key.src_port) } else { src_host },
dst: if f.key.dst_port > 0 { format!("{}:{}", dst_host, f.key.dst_port) } else { dst_host },
src_port: f.key.src_port,
dst_port: f.key.dst_port,
protocol: format!("{}", f.key.protocol),
sent_2s: if use_bytes { f.sent_2s } else { f.sent_2s * 8.0 },
recv_2s: if use_bytes { f.recv_2s } else { f.recv_2s * 8.0 },
sent_10s: if use_bytes { f.sent_10s } else { f.sent_10s * 8.0 },
recv_10s: if use_bytes { f.recv_10s } else { f.recv_10s * 8.0 },
sent_40s: if use_bytes { f.sent_40s } else { f.sent_40s * 8.0 },
recv_40s: if use_bytes { f.recv_40s } else { f.recv_40s * 8.0 },
total_sent: f.total_sent,
total_recv: f.total_recv,
process_name: f.process_name.clone(),
pid: f.pid,
}
}).collect();
let snapshot = JsonSnapshot {
timestamp: chrono::Local::now().to_rfc3339(),
flow_count: json_flows.len(),
total_sent: totals.cumulative_sent,
total_recv: totals.cumulative_recv,
peak_sent: totals.peak_sent,
peak_recv: totals.peak_recv,
flows: json_flows,
};
if let Ok(line) = serde_json::to_string(&snapshot) {
println!("{}", line);
}
std::thread::sleep(refresh);
}
}
fn auto_detect_local_net(interface: Option<&str>) -> Option<(std::net::IpAddr, u8)> {
let devices = pcap::Device::list().ok()?;
let device = if let Some(name) = interface {
devices.into_iter().find(|d| d.name == name)?
} else {
pcap::Device::lookup().ok()??
};
for addr in &device.addresses {
if let std::net::IpAddr::V4(ipv4) = addr.addr {
if ipv4.is_loopback() {
continue;
}
let prefix = addr
.netmask
.and_then(|m| match m {
std::net::IpAddr::V4(mask) => {
Some(u32::from(mask).count_ones() as u8)
}
_ => None,
})
.unwrap_or(24); return Some((std::net::IpAddr::V4(ipv4), prefix));
}
}
None
}