В последнее время все чаще попадаются статьи на тему сетевых технологий, от вдохновения захотелось что-нибудь смастерить. Выбор пал на приложение для мониторинга трафика на локальной машине – сколько отправили/получили и кому/от кого, писать будем на Rust.

Зависимости

  • pcap – позволяет перехватывать те самые пакеты

  • etherparse – для парсинга данных из пакета

  • hickory-proto – обработка dns пакетов, понадобиться для определения доменных имен

  • humansize – для отображение объема трафика в читабельном формате

  • dashmap – concurrent hashmap для хранения статистики

  • ratatui и crossterm – для отрисовки данных в терминале

  • anyhow – маппинг ошибок

Структуры данных

Нас будет интересовать следующая информация:

  • IP адрес, куда/откуда идут пакеты

  • Доменное имя по соответствующему адресу, дабы было понятнее, с кем имеем дело

  • Сколько байтов отправили

  • Сколько байтов получили

  • Сколько всего пакетов отправлено и получено

pub type HostName = String;

pub type StatsMap = Arc<DashMap<IpAddr, HostStats>>;

#[derive(Default, Clone)]
pub struct HostStats {
    pub hostname: Option<HostName>,
    pub bytes_sent: u64,
    pub bytes_received: u64,
    pub packets: u64,
}

impl HostStats {
    pub fn total(&self) -> u64 {
        self.bytes_sent + self.bytes_received
    }
}

#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)]
pub enum Protocol {
    Tcp,
    Udp,
}

И для определения доменного имени заведем локальный кэш IP адрес –> hostname, а чтобы понимать, в какую сторону идет пакет, будем сравнивать адреса из пакета с локальными:

// Для определения hostname по IP адресу и направления трафика
pub struct Attribution {
    // Локальных кэш IP -> hostname
    dns_cache: DashMap<IpAddr, Hostname>,
    // IP адреса локальной машины,
    // с помощью них будем определять направление трафика
    local_ips: HashSet<IpAddr>,
}

impl Attribution {
    pub fn new(local_ips: HashSet<IpAddr>) -> Self {
        Self {
            dns_cache: DashMap::new(),
            local_ips,
        }
    }

    pub fn record_dns(&self, ip: IpAddr, hostname: Hostname) {
        self.dns_cache.insert(ip, hostname);
    }

    pub fn resolve(&self, ip: &IpAddr) -> Option<Hostname> {
        self.dns_cache.get(ip).map(|h| h.clone())
    }

    // Из двух IP адресов определяем remote
    pub fn remote_ip(&self, src: IpAddr, dst: IpAddr) -> Option<IpAddr> {
        if self.local_ips.contains(&src) {
            Some(dst)
        } else if self.local_ips.contains(&dst) {
            Some(src)
        } else {
            None
        }
    }
}

Main

fn main() -> Result<()> {
    let args: Vec<String> = env::args().collect();
    if args.len() != 2 {
        return Err(anyhow!("Invalid arguments, provide network interface name"));
    }
    // Из аргументов получаем имя сетевого интерфейса  
    let device_name = &args[1];
    // Ищем его в списке девайсов
    let device = pcap::Device::list()?
        .into_iter()
        .find(|d| &d.name == device_name)
        .expect(&format!("Network interface {} not found", device_name));

    // Инициализируем структуры данных для хранения статистики
    let local_ips: HashSet<IpAddr> = device.addresses.iter().map(|a| a.addr).collect();
    let attribution = Arc::new(Attribution::new(local_ips));
    let stats: StatsMap = Arc::new(DashMap::new());

    // Запускаем отдельный поток под обработку пакетов (имплементация ниже)
    spawn_capture_thread(device, stats.clone(), attribution);
    // Рендерим статистику в терминале (имплементация ниже)
    run_ui(stats)?;

    Ok(())
}

Обработка пакетов

Из каждого пакета достаем src и dst адрес, определяем направление трафика и учитываем все в общей статистике. Отдельно будем смотреть на DNS пакеты (те, что идут по UDP на 53 порт).

pub fn spawn_capture_thread(device: Device, stats: StatsMap, attribution: Arc<Attribution>) {
    std::thread::spawn(move || {
        // Инициализируем "перехватчик" для выбранного сетевого интерфейса
        let device_name = device.name.clone();
        let mut cap = pcap::Capture::from_device(device)
            .expect(&format!(
                "Could not create Capture from device {}",
                device_name
            ))
            // Отключаем буфферизацию, чтобы получать пакеты сразу
            .immediate_mode(true)
            .open()
            .expect(&format!(
                "Could not activate Capture from device {}",
                device_name
            ));

        // Итерируемся по сетевым пакетам
        while let Ok(packet) = cap.next_packet() {
            let Ok(sliced) = SlicedPacket::from_ethernet(packet.data) else {
                continue;
            };
            
            // Извлекаем IP адреса
            let (src_ip, dst_ip) = match &sliced.net {
                Some(NetSlice::Ipv4(v4)) => (
                    IpAddr::V4(v4.header().source_addr()),
                    IpAddr::V4(v4.header().destination_addr()),
                ),
                Some(NetSlice::Ipv6(v6)) => (
                    IpAddr::V6(v6.header().source_addr()),
                    IpAddr::V6(v6.header().destination_addr()),
                ),
                _ => continue,
            };

            // Извлекаем протокол, будем смотреть только UDP и TCP трафик
            let (protocol, src_port, dst_port, payload, transport_len) = match &sliced.transport {
                Some(TransportSlice::Tcp(t)) => (
                    Protocol::Tcp,
                    t.source_port(),
                    t.destination_port(),
                    t.payload(),
                    packet.data.len(),
                ),
                Some(TransportSlice::Udp(u)) => (
                    Protocol::Udp,
                    u.source_port(),
                    u.destination_port(),
                    u.payload(),
                    packet.data.len(),
                ),
                _ => continue,
            };
            
            // Отдельно смотрим на DNS пакеты,
            // именно из них извлекаем доменные имена и кладем в кэш
            if protocol == Protocol::Udp && (src_port == 53 || dst_port == 53) {
                // Имплементация ниже
                handle_dns_packet(payload, &attribution);
            }

            // Определяем направления трафика, сравнивая IP из пакета с собственными
            let Some(remote_ip) = attribution.remote_ip(src_ip, dst_ip) else {
                continue;
            };
            let is_outgoing = remote_ip == dst_ip;

            // Берем имя хоста из кэша
            let hostname = attribution.resolve(&remote_ip);

            // Обновляем статистику
            let mut entry = stats.entry(remote_ip).or_default();
            if is_outgoing {
                entry.bytes_sent += transport_len as u64;
            } else {
                entry.bytes_received += transport_len as u64;
            }
            entry.packets += 1;
            if hostname.is_some() {
                entry.hostname = hostname;
            }
        }
    });
}

Заполнять кэш доменных имен будем на основе соответствующих записей в DNS пакетах:

pub fn handle_dns_packet(payload: &[u8], attribution: &Attribution) {
    let Ok(msg) = Message::from_vec(payload) else {
        return;
    };

    // Смотрим ответы DNS запросов
    for answer in msg.answers {
        // Берем имя хоста из соответствующих DNS записей
        if matches!(answer.record_type(), RecordType::A | RecordType::AAAA) {
            if let Some(ip) = answer.data.ip_addr() {
                let hostname = answer.name.to_string()
                    .trim_end_matches('.').to_string();
                attribution.record_dns(ip, hostname);
            }
        }
    }
}

Отображение данных в терминале

В live режиме отображаем таблицу с топ 30 по объему трафика, ререндерим каждые 250мс или по нажатию клавиши:

pub fn run_ui(stats: StatsMap) -> io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    loop {
        if event::poll(Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                // Кнопка для выхода
                if key.code == KeyCode::Char('q') {
                    break;
                }
                // Кнопка для обнуления статистики
                if key.code == KeyCode::Char('r') {
                    stats.clear();
                }
            }
        }

        // Сортируем по объему трафика
        let mut rows: Vec<(IpAddr, HostStats)> = stats
            .iter()
            .map(|entry| (*entry.key(), entry.value().clone()))
            .collect();
        rows.sort_by(|a, b| b.1.total().cmp(&a.1.total()));

        terminal.draw(|frame| {
            let area = frame.area();
            
            // Отображаем топ 30 в виде таблицы
            let table_rows: Vec<Row> = rows
                .iter()
                .take(30)
                .map(|(ip, s)| {
                    let label = s.hostname.clone().unwrap_or_else(|| ip.to_string());
                    Row::new(vec![
                        label,
                        ip.to_string(),
                        format_size(s.bytes_sent, BINARY),
                        format_size(s.bytes_received, BINARY),
                        format_size(s.total(), BINARY),
                        s.packets.to_string(),
                    ])
                })
                .collect();

            let widths = [
                Constraint::Percentage(30),
                Constraint::Percentage(20),
                Constraint::Percentage(12),
                Constraint::Percentage(12),
                Constraint::Percentage(14),
                Constraint::Percentage(12),
            ];

            let table = Table::new(table_rows, widths)
                .header(
                    Row::new(vec!["Host", "IP", "Sent", "Received", "Total", "Packets"])
                        .style(Style::default().add_modifier(Modifier::BOLD)),
                )
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .title(" Live Traffic ('q' – quit, 'r' – reset) "),
                )
                .row_highlight_style(Style::default().fg(Color::Yellow));

            frame.render_widget(table, area);
        })?;
    }

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    Ok(())
}

Результат

Запускаем под sudo (без него pcap не будет работать), передав имя интересующего нас сетевого интерфейса (в моем случае "en0"), и начинаем ходить по страницам в браузере:

Раз пакет, два пакет
Раз пакет, два пакет

Возможные улучшения:

  • учет других протоколов

  • уход от DashMap, чтобы избавиться от блокировок

  • дополнительные источники для определения доменных имен

  • возможность блокировать трафик (pcap в такое не умеет)

  • возможность обработки нескольких сетевых интерфейсов одновременно

Ссылки