В последнее время все чаще попадаются статьи на тему сетевых технологий, от вдохновения захотелось что-нибудь смастерить. Выбор пал на приложение для мониторинга трафика на локальной машине – сколько отправили/получили и кому/от кого, писать будем на Rust. Читать далее
В последнее время все чаще попадаются статьи на тему сетевых технологий, от вдохновения захотелось что-нибудь смастерить. Выбор пал на приложение для мониторинга трафика на локальной машине – сколько отправили/получили и кому/от кого, писать будем на Rust.
Зависимостиpcap – позволяет перехватывать те самые пакеты
etherparse – для парсинга данных из пакета
hickory-proto – обработка dns пакетов, понадобиться для определения доменных имен
humansize – для отображение объема трафика в читабельном формате
dashmap – concurrent hashmap для хранения статистики
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
}
}
}Mainfn 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 в такое не умеет)
возможность обработки нескольких сетевых интерфейсов одновременно
Github проекта – https://github.com/veshutov/nets
Мониторинг здорового человека – https://github.com/GyulyVGC/sniffnet
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 0 | 8.78 | 27-06-2026 |
| 2 | Hajiz: rootless Linux‑sandbox на Rust — namespaces, Landlock, seccomp‑BPF и eBPF‑аудит без sudo | 0 | 7.66 | 29-06-2026 |
| 3 | Уважаемые односельчане ! В преддверии празднования 80 - летия Великой ... | 0 | 0 | 21-02-2025 |
| 4 | Багаевская: Утро 18 дек, Вт | 0 | 0 | 17-12-2018 |
| 5 | Спрос на перевозки из Китая и Казахстана в Беларусь вырос в 3 раза | 0 | 0 | 28-01-2025 |
| 6 | Fall in Love with Jayson Home | 0 | 0 | 03-10-2019 |
| 7 | Засідання технічного комітету з гірськолижного спорту і сноубордингу | 0 | 0 | 31-03-2010 |
| 8 | Подскажите сколько адекватно стоит аренда двухкомнатной квартиры недалеко от метро? | 0 | 0 | 10-12-2016 |
| 9 | Эллисон: Это самая быстрая машина Mercedes в истории | 0 | 0 | 14-02-2020 |
| 10 | Зоопарк каналов в Rust: какой выбрать и как не споткнуться | 0 | 8.92 | 23-06-2026 |