Я никогда не понимал, как именно traceroute обнаруживает каждый сетевой переход. Оказывается, всё дело в хитром трюке с TTL и примерно в 80 строках на Rust.

Ранее я писал о том, как настроить a Tailscale узел выхода и попутно разобрал, как именно трафик течёт по проводам в мою домашнюю сеть. Я также хотел чуть лучше понять traceroute. Я никогда особенно не задумывался, как именно она работает. Думаю, сейчас как раз настало время, чтобы это сделать. Чтобы понять, как устроена Traceroute, попробую переписать её на Rust.
Что именно делает traceroute?
При помощи traceroute я попробовал исследовать, как именно запрос проходит с компьютера на роутер и далее в Интернет, пока, наконец, не достигнет конечного сервера.
$ traceroute -m 15 -w 2 8.8.8.8 traceroute to 8.8.8.8 (8.8.8.8), 15 hops max, 40 byte packets 1 <tailscale-gw> (<tailscale-ip>) 6.553 ms 5.323 ms 5.384 ms 2 <home-router> (<router-ip>) 7.183 ms 6.271 ms 4.607 ms 3 * <isp-gateway> (<isp-gateway-ip>) 7.189 ms * 4 * * * 5 * * * 6 * * * 7 <isp-hop-1> (<isp-hop-1-ip>) 284.000 ms 229.201 ms 257.805 ms 8 72.14.223.26 (72.14.223.26) 11.642 ms 12.643 ms 12.868 ms 9 * * * 10 dns.google (8.8.8.8) 12.268 ms 11.907 ms 11.766 ms
Если бегло просмотреть этот код, в нём как бы уровень за уровнем задаётся вопрос «где этот IP?». Пока не вполне понятно, как это происходит.
Но на самом деле это не сама traceroute задаёт вопрос «где этот IP?». Фактически, она использует фокус с TTL (временем жизни пакета).
Чтобы понять, как это происходит, давайте попробуем написать код.
У каждого IP-пакета есть поле TTL (время жизни) – счётчик, который начинается с некоторого значения (обычно 64)
Каждый роутер, переадресующий пакет, уменьшает значение TTL на 1.
Когда в результате этих операций роутеров значение TTL уменьшается до 0, очередной роутер отбрасывает пакет, а отправителю, от которого его получил, отправляет сообщение ICMP “Время истекло”.
В этом сообщении ICMP содержится IP-адрес роутера.
Итак, если мы станем отправлять пакеты с TTL=1, то ответит первый роутер. Если с TTL=2, то ответит второй. И так далее, пока не достигнем пункта назначения. Это и есть traceroute (трассировка маршрута).
Основная идея
Traceroute просто отправляет пакеты, обречённые погибать на очередном сетевом переходе, а затем слушает сообщения об ошибках.
Первая проба
Начнём с единственной функции, которая отправляет один UDP-пакет с заданным TTL и слушает, какой ответ даст ICMP. Почему именно UDP? Потому что это бросовые пакеты, участь которых — погибнуть в пути. При работе с ними не требуется на рукопожатия TCP, ни гарантий доставки. Мы просто стреляем байтами через порт и дожидаемся, пока роутеры сообщат нам, что отбросили эти пакеты.
use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use std::mem::MaybeUninit; use std::net::{Ipv4Addr, SocketAddrV4}; use std::time::Duration; fn probe(target: Ipv4Addr, ttl: u32) -> std::io::Result<Option<Ipv4Addr>> { // UDP-сокет для отправки пробы let send_sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; send_sock.set_ttl_v4(ttl)?; // Необработанный сокет ICMP, нужный только для перехвата откликов «Время истекло» let recv_sock = Socket::new( Domain::IPV4, Type::from(libc::SOCK_RAW), Some(Protocol::ICMPV4), )?; recv_sock.set_read_timeout(Some(Duration::from_secs(2)))?; // Отправляем пакет UDP на порт с большим номером (33434) let dest = SockAddr::from(SocketAddrV4::new(target, 33434)); send_sock.send_to(&[0u8; 32], &dest)?; // Слушаем отклик ICMP let mut buf = [MaybeUninit::<u8>::uninit(); 512]; match recv_sock.recv(&mut buf) { Ok(n) => { // Вопрос безопасности: recv записал n байт в buf let buf: &[u8] = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, n) }; // На IP-заголовок приходятся первые 20 байт, IP источника находится в байтах 12-16 if buf.len() >= 20 { let ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); Ok(Some(ip)) } else { Ok(None) } } Err(_) => Ok(None), // задержка = отклика нет (*) } } fn main() -> std::io::Result<()> { let target = Ipv4Addr::new(8, 8, 8, 8); // Google DNS for ttl in 1..=15 { let hop = probe(target, ttl)?; match hop { Some(ip) => println!("{:>2} {}", ttl, ip), None => println!("{:>2} *", ttl), } if hop == Some(target) { break; } } Ok(()) }
Давайте разберём этот код.
Строки 7-9: Мы создаём обычный UDP-сокет и устанавливаем его TTL. Это ключевой фокус: мы целенаправленно задаём низкое значение TTL, чтобы пакет погиб до того, как достигнет места назначения.
Строки 12-17: Создаём второй, на этот раз — необработанный ICMP-сокет. Он слушает все ICMP-пакеты, поступающие на нашу машину, в том числе, отклики «Время истекло» от тех роутеров, которые отбросили наш короткоживущий UDP-пакет. Здесь нам требуется libc::SOCK_RAW, так как socket2 не предоставляет напрямую необработанные сокетные типы. Соответственно, чтобы открыть его, нам потребуется root/sudo.
Строки 20-21: Мы отправляем 32 зануленных байта на порт 33434 целевой машины. Их содержимое не имеет значения. Порт 33434 традиционно используется именно для traceroute, то есть, для трассировки маршрута. Через него мы больше ничего не слушаем, поэтому, когда наш порт, наконец, придёт по назначению, целевая машина пришлёт в ответ ICMP “Порт недоступен”, а не “Время истекло” — именно так мы и узнаем, что пакет прибыл по назначению.
Строки 24-38: считываем информацию из необработанного сокета ICMP. В ответ получаем необработанный IP-пакет. Первые 20 его байт содержат заголовок IP, а байты 12-15 — адрес того источника, который отправил ICMP-отклик (то есть, того роутера, который отбросил наш пакет). Здесь мы пользуемся MaybeUninit, поскольку Rust не позволяет читать неинициализированную память. Блок unsafe здесь на самом деле безопасен, поскольку recv сообщает, сколько именно байт было записано.
Строки 42-55: главный цикл. В каждой итерации мы увеличиваем TTL на единицу, доводя его значение от 1 до 15, выводя на экран каждый сетевой переход. Если полученный в ответ IP совпадает с адресом нашей цели — это значит, что мы достигли пункта назначения, и выходим из цикла.
Поскольку мы используем необработанный сокет ICMP, для выполнения этого кода нам требуется sudo.
$ sudo cargo run 1 <tailscale-ip> 2 <router-ip> 3 <isp-gateway-ip> 4 * 5 * 6 * 7 <isp-hop-1-ip> 8 72.14.223.26 9 * 10 8.8.8.8
Работает! Видим наш шлюз Tailscale, домашний роутер, Интернет-провайдер и сеть Google. Но здесь есть две проблемы. Во-первых, программа «не знает», когда остановиться (проделывает весь путь вплоть до перехода 15). Во-вторых, мы получаем всего одну пробу на каждое значение TTL без какой-либо информации о тайминге.
Некоторые упрощения
Traceroute при каждой пробе увеличивает значение порта на единицу. Но по традиции оригинальная версия traceroute, написанная ван Якобсоном, использовала порт 33434. При увеличении номера порта на единицу при каждой пробе становится проще соотносить отклики с конкретными пробами, поскольку исходный заголовок UDP встраивается внутри отклика ICMP «Время истекло».
Traceroute также поддерживает режим TCP при использовании опции -T, что позволяет учесть и те сети, в которых установлены брандмауэры, блокирующие UDP, но пропускающие TCP. Но принцип действует один и тот же. Устанавливаем низкий TTL, допускаем гибель пакета, считываем ошибку ICMP.
Что такое ICMP?
ICMP — это протокол управления сообщениями Интернета (Internet Control Message Protocol). Именно по этому протоколу в Интернете передаются сообщения об ошибках, а не данные. Я видел ошибки ICMP, ещё даже не осознавая, что это именно они. Когда мы пингуем «Destination Host Unreachable» (Хост назначения недоступен) — это ICMP типа 3. Аналогично, «Time Exceeded» (Время истекло) — это ICMP типа 11, именно на эти значения мы и будем полагаться.
Я понимаю это так: если я отправляю письмо, а получатель мне отвечает «Я не понимаю, о чём оно», то это HTTP-ошибка. Если почтовая служба возвращает мне письмо со штампом «Адрес получателя не существует», то это ICMP.
Вот как выглядит пакет с откликом ICMP, когда мы получаем его на необработанном сокете:
Сначала идёт заголовок IP (байты 0-19), затем с байта 20 начинается сообщение ICMP. В полезной нагрузке ICMP также содержатся заголовки нашего оригинального пакета, и именно на основе этой информации реальная traceroute соотносит отклики с конкретными пробами.
Рассмотрев код, находим вот эту часть, в которой делается синтаксический разбор отклика ICMP:
let buf: &[u8] = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, n) }; // Заголовок IP занимает первые 20 байт, причём, IP источника записан в байтах 12-16 if buf.len() >= 20 { let ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); Ok(Some(ip)) }
На данном этапе мы всего лишь читаем IP источника, записанный в IP-заголовке. Но само сообщение ICMP начинается с байта 20, и в его первом байте записан тип. Мы его полностью игнорируем, и именно поэтому наша traceroute не знает, где остановиться. Если бы мы проверяли buf[20], то могли бы различать Тип 11 (Время Истекло, на очередном роутере, встретившемся пакету по пути) и Тип 3 (Пункт назначения недостижим — то есть, мы прибыли).
Такой жёсткий парсинг байтов — несколько излишнее усложнение жизни самим себе. В Rust есть пакет pnet_packet, идиоматически обрабатывающий все эти задачи, но хочется понять, что именно содержится в этом пакете.
Надо знать, где остановиться
Давайте исправим нашу traceroute так, чтобы она знала, когда прибудет к месту назначения. Сначала заменим возвращаемый тип Option<Ipv4Addr> перечислением, которое будет схватывать три возможных исхода:
enum ProbeResult { Hop(Ipv4Addr), // Тип 11 – Время истекло Reached(Ipv4Addr), // Тип 3 – Пункт назначения недоступен Timeout, // Ответа нет }
Далее мы проверяем buf[20] (байт ICMP с информацией о типе) после того, как извлечём IP-адрес узла-источника:
if buf.len() >= 21 { let ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); match buf[20] { 11 => Ok(ProbeResult::Hop(ip)), 3 => Ok(ProbeResult::Reached(ip)), _ => Ok(ProbeResult::Timeout), } }
Поэтому теперь по достижении Reached мы можем выйти из главного цикла
match hop { ProbeResult::Hop(ip) => println!("{:>2} {}", ttl, ip), ProbeResult::Reached(ip) => { println!("{:>2} {}", ttl, ip); break; } ProbeResult::Timeout => println!("{:>2} *", ttl), }
Забавно, что, выполняя этого код, я нашёл баг в системе проверки типов. Он слишком наивен и не вызывал подозрений. По идее, мы должны были сообщить, что достигли места назначения, лишь если ip равен IP-адресу точки назначения. В противном случае в результате запуска этого кода мы получали следующее.
$ sudo cargo run 1 <tailscale-ip> 2 <router-ip> 3 192.168.0.1 match buf[20] { 11 => Ok(ProbeResult::Hop(ip)), 3 if ip == target => Ok(ProbeResult::Reached(ip)), 3 => Ok(ProbeResult::Hop(ip)), _ => Ok(ProbeResult::Timeout), }
После таких правок наша версия traceroute правильно останавливается на 8.8.8.8:
$ sudo cargo run 1 <tailscale-ip> 2 <router-ip> 3 <isp-gateway-ip> 4 * 5 * 6 * 7 <isp-hop-1-ip> 8 72.14.223.26 9 * 10 8.8.8.8
Добавляем тайминг
Сейчас в нашем выводе не хватает тайминга. В реальной traceroute указывается, сколько времени у каждой пробы уходит на путь туда и обратно. Исправить это просто: Instant::now() до send, elapsed() после recv. Мы обновляем перечисление так, чтобы добавить в него информацию о длительности:
use std::time::Instant; enum ProbeResult { Hop(Ipv4Addr, Duration), Reached(Ipv4Addr, Duration), Timeout, }
Затем в probe обёртываем send/recv в таймер:
let start = Instant::now(); send_sock.send_to(&[0u8; 32], &dest)?; // ... логика recv ... let elapsed = start.elapsed(); // возвращаем ProbeResult::Hop(ip, elapsed) etc.
Далее выводим время в миллисекундах:
ProbeResult::Hop(ip, rtt) => { println!("{:>2} {} {:.3} ms", ttl, ip, rtt.as_secs_f64() * 1000.0) }
$ sudo cargo run 1 <tailscale-ip> 6.091 ms 2 <router-ip> 4.907 ms 3 <isp-gateway-ip> 6.363 ms 4 * 5 * 6 * 7 <isp-hop-1-ip> 12.652 ms 8 72.14.223.26 12.196 ms 9 * 10 8.8.8.8 12.197 ms
Теперь видно, какова задержка на каждом переходе. Далее мы переходим с ~6 мс (локальный интернет-провайдер) к ~12 мс на переходе 7, после которого наш трафик покидает локальную сеть и попадает в сеть Google.
Три пробы на переход
Traceroute отправляет по три пробы с каждым значением TTL. Вот почему в оригинальном выводе наблюдается по 3 значения тайминга на каждый переход:
8 72.14.223.26 11.642 ms 12.643 ms 12.868 ms
Я заинтересовался, что это может быть такое, и оказалось, что traceroute делает так по трём причинам:
Изменчивость: в сетевой задержке наблюдаются флуктуации. Измеренное значение может оказаться выбросом. Три значения позволяют нащупать некоторую регулярность.
Надёжность: если в работе одной из проб возникает задержка, но от двух остальных удаётся получить отклики, то мы всё равно видим, где находится сетевой переход. Таким образом, при наличии одной * среди реальных значений времени означает «неустойчивый», а не «мёртвый».
Обнаружение балансировщика нагрузки: если разные пробы с одинаковым временем жизни попадают на хосты с разными IP-адресами — значит, мы имеем дело с балансировщиком нагрузки. На самом деле, приступая к подготовке этого поста, я сначала использовал в качестве точки назначения github.com и раз за разом попадал на балансировщик нагрузки.
В коде я просто обёртываю имеющийся вызов probe() в небольшой внутренний цикл. Мы отслеживаем, какой последний IP-адрес, который успели увидеть, и делаем вывод в консоль лишь в случае его изменения. Благодаря этому вывод остаётся чистым:
let mut reached = false; let mut last_ip: Option<Ipv4Addr> = None; print!("{:>2} ", ttl); for _ in 0..3 { match probe(target, ttl)? { ProbeResult::Hop(ip, rtt) => { if last_ip != Some(ip) { print!("{} ", ip); last_ip = Some(ip); } print!("{:.3} ms ", rtt.as_secs_f64() * 1000.0); } ProbeResult::Reached(ip, rtt) => { if last_ip != Some(ip) { print!("{} ", ip); last_ip = Some(ip); } print!("{:.3} ms ", rtt.as_secs_f64() * 1000.0); reached = true; } ProbeResult::Timeout => print!("* "), } } println!(); if reached { break; }
$ sudo cargo run 1 <tailscale-ip> 5.713 ms 4.993 ms 4.739 ms 2 <router-ip> 5.355 ms 5.082 ms 4.998 ms 3 * * * 4 * * * 5 * * * 6 * * * 7 <isp-hop-1-ip> 15.658 ms 12.088 ms 11.362 ms 8 72.14.223.26 11.978 ms 12.555 ms 12.344 ms 9 * * * 10 8.8.8.8 14.246 ms 13.244 ms 12.892 m
Сравнение нашей программы с реальной traceroute
К данному моменту меня уже всё устраивает. Я понял о traceroute больше, чем знал когда-либо. Но при этом мне хотелось выяснить, чего не хватает в моей реализации.
Возможность |
Реальная traceroute |
Наш аналог |
Инкремент TTL |
Да |
Да |
Проверка типов ICMP |
Да |
Да |
Тайминг (путь туда и обратно) |
Да |
Да |
3 пробы на переход |
Да |
Да |
Обратный просмотр DNS |
Да (напр. dns.google) |
Нет |
Увеличение номера порта на единицу с каждой пробой |
Да (33434, 33435…) |
Нет (фиксированное 33434) |
Эхо-режим ICMP (-I) |
Да |
Нет (только UDP) |
Режим TCP (-T) |
Да |
Нет |
Поддержка IPv6 |
Да (traceroute6) |
Нет |
Чего не показывает traceroute
Выстраивая этот проект, я осознал, что вывод traceroute выглядит как карта сети, но скорее как набросок карты. Некоторые происходящие события traceroute выявить не может, а именно:
Асимметричные пути возврата: каждый ICMP-отклик от каждого роутера проделывает собственный обратный путь, который может быть совершенно иным, нежели прямой путь. В выводе отображаются роутеры, встреченные на прямом пути, но невозможно отследить, каким путём отклики возвращались обратно.
MPLS-туннели: на пути переключения меток пакет может посетить множество роутеров, но traceroute показывает либо один сетевой переход, либо ни одного.
Расщепление пути под действием балансировщика нагрузки: последовательно посылаемые пробы с одинаковым TTL могут попадать на разные роутеры. Тот «путь», который мы видим не является ни одним конкретным путём, которым мог бы проследовать любой пакет. Я столкнулся с этой проблемой, пытаясь работать с github.com.
Ограничение частоты ICMP: Переходы * * *, с которыми мы имеем дело, не обязательно ведут к мёртвым роутерам. Многие роутеры понижают приоритет ICMP или просто отбрасывают их, чтобы сэкономить ресурсы ЦП. Пакеты нормально через них проходят, просто роутер не считает нужным на них откликаться.
Почему мы видим *
После всего этого я по-прежнему немного не понимал, почему же мы видим ряды * * . В нашем коде мы выводим такие символы, когда возникает задержка, но мы при этом не выходим из цикла командой break. Можно ли застрять в бесконечных как в чистилище, если не ограничить допустимое количество сетевых переходов?
Мы видим * по следующим причинам:
Ограничение частоты ICMP: роутер работает нормально и как следует переадресует пакеты, но снижает приоритет генерации откликов ICMP «Время истекло», чтобы сэкономить ресурсы ЦП. Это наиболее распространённая причина.
Блокировка на уровне брандмауэра: роутер или брандмауэр, которые требуется преодолеть, просто отбрасывает ICMP. Корпоративные и облачные брандмауэры продавливают именно такую политику.
Брандмауэр блокирует UDP на порту 33434: роутер отбрасывает нашу пробу даже до того, как у неё появится возможность уменьшить TTL на единицу. Естественно, в таком случае пакет не погибает.
Задержка при чтении: роутер ответил, но на отклик ушло более 2 секунд (предусмотренная задержка), и он не успел прийти. В современных сетях случается редко.
Отклик был переадресован не там, где ожидалось. ICMP-отклик был сгенерирован, но потерялся или был перенаправлен по какому-то неизвестному пути, поэтому к нам так и не пришёл.
Звёздочка (*) означает «мы не получили ответа», а не «там ничего нет». Это подтверждает и вывод сделанной нами traceroute: все переходы от 3 до 6 это , но переход 7 всё равно проявляется. Таким образом, пакеты нормально проходят через эти молчащие роутеры.
Почему при этом требуется sudo?
Мне раз за разом приходилось выполнять sudo cargo run, что меня раздражало. В обычной traceroute такой необходимости нет. Так что я заглянул туда.
Наш код открывает ICMP-сокет SOCK_RAW, чтобы напрямую читать отклики. Необработанные сокеты обслуживаются в приоритетном режиме, поскольку они могут анализировать произвольный сетевой трафик. Соответственно, работать с ними ядру приходится с правами root.
Системная версия traceroute это обходит, поскольку устанавливается с активированным битом setuid. Выполнив нечто вроде ls -la $(which traceroute), получим вывод наподобие -r-xr-sr-x с флагом s, а значит — бинарные прогоны с повышенными привилегиями, независимо от инициатора вызова.
Под macOS есть и третий вариант: сокеты датаграмм ICMP (SOCK_DGRAM с IPPROTO_ICMP), разрешённые ядром для непривилегированных пользователей. Они более ограничены, чем необработанные сокеты, но их достаточно для элементарного пингования и трассировки маршрута.
Заключение
Я затеял этот проект, глубоко задумавшись об устройстве Tailscale. Это часть более масштабного проекта, которым я занимался: несколько вечеров пытался лучше понять современный Интернет. Далее собираюсь почитать научные статьи по WireGuard и подробнее разобраться, как работает плоскость управления Tailscale. Многие вещи, касающиеся этой области, кажутся мне увлекательными как с точки зрения архитектуры распределённых систем, так и с точки зрения программирования.
Не жалею, как провёл этот вечер. В одной из компаний, где мне довелось поработать, блокировали ping, что на тот момент казалось мне неприемлемым. Очень рад, что могу написать мою собственную traceroute и заниматься отладкой, если в будущем какая-то другая компания тоже решит мне что-нибудь запретить.
Код к этому посту выложен на GitHub. Вот его окончательная версия:
use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use std::mem::MaybeUninit; use std::net::{Ipv4Addr, SocketAddrV4}; use std::time::{Duration, Instant}; enum ProbeResult { Hop(Ipv4Addr, Duration), // Тип 11 – Время истекло Reached(Ipv4Addr, Duration), // Тип 3 – Пункт назначения недоступен Timeout, // Ответа нет } fn probe(target: Ipv4Addr, ttl: u32) -> std::io::Result<ProbeResult> { let send_sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; send_sock.set_ttl_v4(ttl)?; let recv_sock = Socket::new( Domain::IPV4, Type::from(libc::SOCK_RAW), Some(Protocol::ICMPV4), )?; recv_sock.set_read_timeout(Some(Duration::from_secs(2)))?; let dest = SockAddr::from(SocketAddrV4::new(target, 33434)); let start = Instant::now(); send_sock.send_to(&[0u8; 32], &dest)?; let mut buf = [MaybeUninit::<u8>::uninit(); 512]; match recv_sock.recv(&mut buf) { Ok(n) => { let buf: &[u8] = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, n) }; if buf.len() >= 21 { let ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); let elapsed = start.elapsed(); match buf[20] { 11 => Ok(ProbeResult::Hop(ip, elapsed)), 3 if ip == target => Ok(ProbeResult::Reached(ip, elapsed)), 3 => Ok(ProbeResult::Hop(ip, elapsed)), _ => Ok(ProbeResult::Timeout), } } else { Ok(ProbeResult::Timeout) } } Err(_) => Ok(ProbeResult::Timeout), } } fn main() -> std::io::Result<()> { let target = Ipv4Addr::new(8, 8, 8, 8); for ttl in 1..=15 { let mut reached = false; let mut last_ip: Option<Ipv4Addr> = None; print!("{:>2} ", ttl); for _ in 0..3 { match probe(target, ttl)? { ProbeResult::Hop(ip, rtt) => { if last_ip != Some(ip) { print!("{} ", ip); last_ip = Some(ip); } print!("{:.3} ms ", rtt.as_secs_f64() * 1000.0); } ProbeResult::Reached(ip, rtt) => { if last_ip != Some(ip) { print!("{} ", ip); last_ip = Some(ip); } print!("{:.3} ms ", rtt.as_secs_f64() * 1000.0); reached = true; } ProbeResult::Timeout => print!("* "), } } println!(); if reached { break; } } Ok(()) }
Ссылки
Другой мой пост об узлах выхода в Tailscale
Оригинальная traceroute ван Якобсона
Комментарии (14)

Akina
14.04.2026 15:12Каждый роутер, переадресующий пакет, уменьшает значение TTL на 1.
Увы, это как раз не догма. Обычно - да, но не всегда.
Роутер запросто может не изменять TTL. Как правило, используется, чтобы спрятать узел маршрутизации, а то и целый сегмент, если таких роутеров в трассе несколько. По факту узел есть, но трассировка не покажет его.
Реже встречается обратная ситуация - роутер уменьшает TTL более чем на 1. Например, как способ сделать "более дорогим" путь через себя для протоколов динамической маршрутизации и немного разгрузиться. В этом случае такой узел будет присутствовать в отчёте на нескольких хопах подряд.

Gansterito
14.04.2026 15:12"более дорогим" путь через себя для протоколов динамической маршрутизации
Что это за протоколы маршрутизации такие???

HardWrMan
14.04.2026 15:12Я никогда не понимал, как именно traceroute обнаруживает каждый сетевой переход. Оказывается, всё дело в хитром трюке с TTL
Хотя это довольно-таки прозрачно описано в документации и даже находило отклик в фольклоре:
Traceroute
Представь себе, что ты живешь на 9м этаже и хочешь узнать всех жильцов которые живут от тебя до Клавки с 3го. Ты берешь взрывпакет и, исходя из формулы свободного падения, рассчитываешь время взрыва пакета над 8м этажом. Это TTL=1. После того как пакет ибанет - выглянет озверевшая рожа соседа с 8го этажа. Время реакции зависит от загруженности сервера, т.е. от занятости соседа и от шейпов, т.е. в воздухе ли ваша система или ты живешь на планете, где атмосфера жидкий азот. Так вот, если вообще не дождешься ответа - твой сосед глухой - у него запрещены icmp ответы, либо он запретил их только для тебя если его уже подзаебали твои финты и он научился тебя игнорировать. Дальше выставляешь TTL=2 и т.д. Не забывай, что если Клавка живет выше тебя – это No route to hostБольше народного творчества
DHCP
Вы проснулись после дикой пьянки. Первые ваши слова «кто я?» и «где я?». Сосед, который не запивал водку пивом, вам сообщает все ваши параметры: кто вы и где. Этот сосед выступает в роли DHCP-сервера. Учтите, что в сети могут быть так называемые «ложные DHCP-сервера», например жена – на ваш вопрос «кто я?» она выдаст неверную информацию: «алкоголик ты проклятый». Так что не всегда динамическая выдача параметров безопасна, рекомендуется записывать свои параметры (как зовут, ваш адрес и т.д.) на бумажке.
Маршрут по умолчанию
Подойдите к прохожему и спросите «не подскажете ли вы как пройти к моргу имени Немировича-Данченко?». С большой долей вероятности вас пошлют нахуй. Так вот это и есть маршрут по умолчанию, другими словами - если адрес назначения не известен, то пакеты посылаются на маршрут по умолчанию (синонимы: шлюз по умолчанию, default gateway).
Понятие TTL
Представьте себе, что вам 5 лет и вы хотите кушать. Вы идете к папе и говорите: «Папа, я хочу кушать». Ваш папа смотрит телевизор, согласно таблице маршрутизации о посылает вас к маме. Вы идете к ней и просите «Мамааа, я хочу кушать». Мама болтает с подругой по телефону и согласно своей таблице маршрутизации посылает вас к папе. И так вы ходите как дурак от папы к маме и обратно, туда-сюда, туда-сюда, а все потому что криворукие админы (родители папы и мамы) неправильно настроили таблицу маршрутизации. Чтобы защититься от таких ситуаций придумали понятие TTL (Time To Live), что применительно к нашей ситуации означает количество терпения у мальчика, пока он не скажет «заебало» и не упадет перед ногами мамы или папы в беспомощном состоянии. Последний, по правилам (стандарты – это «так заведено в семье»), обязан послать короткий нелестный отзыв адрес того, кто послал мальчика кушать. Это так называемый icmp-пакет «мальчик издох»
Ping
Вы конечно бывали в ситуации «сам дурак». Вы кричите «Петя, ты еблан», а в ответ слышите «Вася, сам еблан». Это простеший пинг. Вы только что пропинговали Васю. Не все отвечают на пинги, особо культурные, например Microsoft.com не утруждают себя реагированием на ваши запросы. С такими переругиваться бесполезно, мы знаем, что они слышат и злятся, но реакции добиться не можем. Тем не менее, пинг – неплохой способ узнать жив ли хост, ведь пиная труп ногами не добьешься реакции «сам дурак»
Traceroute
Представь себе, что ты живешь на 9м этаже и хочешь узнать всех жильцов которые живут от тебя до Клавки с 3го. Ты берешь взрывпакет и, исходя из формулы свободного падения, рассчитываешь время взрыва пакета над 8м этажом. Это TTL=1. После того как пакет ибанет - выглянет озверевшая рожа соседа с 8го этажа. Время реакции зависит от загруженности сервера, т.е. от занятости соседа и от шейпов, т.е. в воздухе ли ваша система или ты живешь на планете, где атмосфера жидкий азот. Так вот, если вообще не дождешься ответа - твой сосед глухой - у него запрещены icmp ответы, либо он запретил их только для тебя если его уже подзаебали твои финты и он научился тебя игнорировать. Дальше выставляешь TTL=2 и т.д. Не забывай, что если Клавка живет выше тебя – это No route to host
DNS
Представь, что на дворе 17й год, ты - вождь мирового пролетариат и тебе нужно срочно попасть в одно место. Ты поднимаешь трубку и начинаешь орать "Але, багышня, багышня, мне Смольный!" Если ты слышишь, как "багышня" начинает орать: "Дуськ, а Дуськ? Опять этот мудак картавый звонит! Где эта су... Лизавета Павловна... Смольный на ейном участке?" Это рекурсивный ДНС- сервер. Если она молча передает трубку Дуське, а та, выслушав тебя, Лизавете Павловне - сервер нерекурсивный.

Pochemuk
14.04.2026 15:12Не совсем понятно, как будет проходить обратный ICMP-пакет через NAT?
Особенно, если на промежуточных роутерах включен Symmetric NAT или Port Restricted NAT или Adress Restricted NAT?

Daiichi
14.04.2026 15:12Не совсем понятно, как будет проходить обратный ICMP-пакет через NAT?
Stateful packet inspection (SPI) for NAT. Если есть трансляция, открытая исходящим пакетом, и разрешено прохождение пакетов, относящихся к этому соединению, то оповещения ICMP, принятые маршрутизатором с NAT, будут перенаправлены этим маршрутизатором отправителю исходного пакета. Относится ли пакет с оповещением ICMP к какому-либо ранее открытому соединению, или нет, маршрутизатор узнаёт путём анализа вложенного в этот пакет исходного пакета (с уже транслированными им адресами/портами).
Например, в iptables для SPI предназначен модуль conntrack, и если в таблицу FORWARD добавить правило c разрешением (--jump ACCEPT) прохождения пакетов, связанных (RELATED) с состоянием соединения (connection state, --cstate):
-m conntrack --cstate RELATED --jump ACCEPT, то это разрешит прохождение ответов ICMP даже в том случае, когда исходный пакет соединения был подвергнут трансляции адресов/портов.Особенно, если на промежуточных роутерах включен Symmetric NAT или Port Restricted NAT или Adress Restricted NAT?
Виды NAT не имеют отношения к прохождению через них оповещений ICMP. Для прохождению ответов ICMP требуется, чтобы на этих самых промежуточных маршрутизаторах была включена и правильно настроена SPI for NAT для трафика, связанного с состоянием соединения.

Pochemuk
14.04.2026 15:12Ого!
Т.е. исходящий проброс будет открыт NAT на шлюзе для UDP, но обратно он будет пропускать и ICMP-ответы?
Но кое-что всё равно не ясно:
Вот послали мы UDP-пакет на некий IP на порт 33434. Но ответ при достижении TTL=0 придет совсем с другого IP.
Т.е., даже если включен на шлюзе Symmetric NAT или Adress Restricted NAT, то такой ICMP-ответ не будет сходу отброшен (как ответ для неизвестного вопрошающего), а сначала будет вскрыт, узнан порт исходящего UDP-соединения, адрес вопрошающего и ему туда ответ будет перенаправлен. Правильно понимаю?

Daiichi
14.04.2026 15:12Вот послали мы UDP-пакет на некий IP на порт 33434. Но ответ при достижении TTL=0 придет совсем с другого IP.
Ответ в рамках того же соединения не придёт, придёт оповещение ICMP, а это уже совершенно другой протокол, который в установленное ранее соединение уже не вписывается. То есть, межсетевой экран (МСЭ) на маршрутизаторе знает про соединение, например, TCP/UDP A.A.A.A:PortA <—> B.B.B.B:PortB, а ему внезапно прилетит ICMP C.C.C.C/TTL expired, которое и не TCP, и не UDP, и адрес отправителя в нём не B.B.B.B, ну и про порты в нём вообще ничего нет.
МСЭ, который не умеет SPI, такой пакет просто тупо удавит. Чтобы не сломать интернет, таким МСЭ приходится разрешать пропуск вообще любых ICMP TTL expired, admin prohibited и всё семейство unreachable откуда угодно, что представляет собой определённый риск.
А вот МСЭ, который умеет SPI, и у которого эта самая SPI включена, ловко расковыривает любое оповещение ICMP, достаёт из него оригинальный пакет, устанавливает по нему сведения о протоколе, а также те самые A.A.A.A:PortA, B.B.B.B:PortB, находит по ним соединение в своей таблице соединений и понимает, что он должен отправить оповещение ICMP в адрес инициатора соединения A.A.A.A. Ну или давит это оповещение ICMP, если искомое соединение в своей таблице он так и не нашёл.
C SPI для NAT просто добавляется ещё один шаг. Найдя совпадающее соединение, МСЭ затем смотрит в дополнительные поля записи таблицы соединений, в которых хранятся оригинальные адреса/номера портов до трансляции, выбирает из нужного поля оригинальный адрес инициатора соединения и отправляет оповещение уже ему.
Т.е., даже если включен на шлюзе Symmetric NAT или Adress Restricted NAT, то такой ICMP-ответ не будет сходу отброшен (как ответ для неизвестного вопрошающего), а сначала будет вскрыт, узнан порт исходящего UDP-соединения, адрес вопрошающего и ему туда ответ будет перенаправлен. Правильно понимаю?
Правильно понимаете. SPI — это великое изобретение, существенно упрощающее настройку МСЭ, а на его изобретателей вообще надо молиться :)

HardWrMan
14.04.2026 15:12МСЭ, который не умеет SPI, такой пакет просто тупо удавит.
Забавно, но я таких со своей стороны практически не встречал. Трейс в интранет всегда работал корректно через все бытовые (и не очень) NATы на роутерах, шлюзах на основе всяких OS (были и Linux и FreeBSD и даже Windows). Можете назвать реальный пример такого, чтобы самому пощупать?

Daiichi
14.04.2026 15:12Забавно, но я таких со своей стороны практически не встречал.
Ну так я ж давно живу. Я ещё ipchains настраивал, в них никакого conntrack'а не было. И древние циски родом из 90-х годов прошлого века тоже настраивал, в которых даже расширенных ACL не было, не говоря уже о CBAC или ZFW.
Трейс в интранет всегда работал корректно через все бытовые (и не очень) NATы на роутерах, шлюзах на основе всяких OS (были и Linux и FreeBSD и даже Windows).
Что совершенно неудивительно, потому что в них уже был встроен SPI с поддержкой NAT. Я ж говорю, создателям SPI надо памятник при жизни поставить.
Можете назвать реальный пример такого, чтобы самому пощупать?
Самый простой вариант заключается в том, чтобы взять любой работающий линуксовый МСЭ на iptables, удалить из его таблиц все правила, в которых фигурирует conntrack, после чего можно будет поглядеть на отвалившийся PMTUD и на неработающую трассировку.
Команда для удаления всех правил, в которых упомянут conntrack:
sudo iptables -S \ | sed -nEe '/\<conntrack\>/ s!^-A\>!iptables -D!p' \ | sudo /bin/shЕсли хочется приобщиться к старине, найдите где-нибудь древний маршрутизатор Cisco, разработанный и выпущенный в конце 90-х — начале 00-х, поставьте на него не менее древний Cisco IOS, в feature set которого отсутствуют упоминания о Context-Based Access Control (CBAC) и Zone-Based Policy Firewall (ZFW), ну или хотя бы не включайте их, если они там есть, и насладитесь оригинальным устойчивым вкусом. Cisco ASA не подойдут, они сделаны на базе линуха с IPtables, и потому в них SPI была изначально, и сразу с NATом.

HardWrMan
14.04.2026 15:12любой работающий линуксовый МСЭ на iptables, удалить из его таблиц все правила, в которых фигурирует conntrack
А что на счёт FreeBSD и ipfw?

Daiichi
14.04.2026 15:12А что на счёт FreeBSD и ipfw?
А насчёт ipfw, да и любых других МСЭ можно получить вполне исчерпывающую консультацию гугловского ИИ, поискав в гугле "<название МСЭ> stateful packet inspection", возможно, "with examples". Из объяснений вполне можно понять, какие правила из конфигурации МСЭ надо исключить, чтобы эту самую SPI в нём отключить, или, наоборот, какие — добавить, чтобы её включить. Вот, например: "ipfw stateful packet inspection with examples".

HardWrMan
14.04.2026 15:12Ну да, универсальный ответ "тебя что, в гугле забанили?" (с)

Daiichi
14.04.2026 15:12Ну да, универсальный ответ "тебя что, в гугле забанили?" (с)
Нормальный ответ, я считаю. Мне всё равно приходится консультироваться по поводу деталей в этом самом гугле, чтобы не наврать в своих ответах, так почему бы и не устранить лишнее звено в моём лице из процесса получения знаний?
V1tol
Для такого простого примера можно было и не использовать unsafe. Достаточно было просто объявить
а потом ниже использовать как