Считаем пакеты на Rust

от автора

В последнее время все чаще попадаются статьи на тему сетевых технологий, от вдохновения захотелось что-нибудь смастерить. Выбор пал на приложение для мониторинга трафика на локальной машине – сколько отправили/получили и кому/от кого, писать будем на 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 в такое не умеет)

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

Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1055582/