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

Ранее я писал о том, как настроить a Tailscale узел выхода и попутно разобрал, как именно трафик течёт по проводам в мою домашнюю сеть. Я также хотел чуть лучше понять traceroute. Я никогда особенно не задумывался, как именно она работает. Думаю, сейчас как раз настало время, чтобы это сделать. Чтобы понять, как устроена Traceroute, попробую переписать её на Rust.
Что именно делает traceroute?
При помощи traceroute я попробовал исследовать, как именно запрос проходит с компьютера на роутер и далее в Интернет, пока, наконец, не достигнет конечного сервера.
$ traceroute -m 15 -w 2 8.8.8.8traceroute 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-16if 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.1match 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 ван Якобсона
ссылка на оригинал статьи https://habr.com/ru/articles/1023442/