MTR, Path MTU и детект блокировок по SNI на iOS без entitlements

от автора

В апреле я выложил в App Store NetDiag+ — набор сетевых инструментов для iOS: ping, traceroute, DNS, whois, сканер портов и LAN. Главная фишка была в том, что всё это работает без приватных entitlements — на чистом BSD-сокете через C-interop. В мае я написал об этом на Habr, статья зашла в основные хабы, дала всплеск установок.

За следующие полтора месяца я добавил 9 новых инструментов. Изначально казалось, что это будет «ещё немного того же» — обернуть пару сокетов в SwiftUI. На деле почти каждый тул оказался отдельной технической задачей: где-то пришлось разбирать сырые ICMP-пакеты, где-то выяснилось, что «очевидный» подход просто не работает в реальной сети, а где-то — что TCP-проба врёт про доступность.

Расскажу про самые интересные.


1. MTR — непрерывный traceroute, который не должен тормозить

mtr (My TraceRoute) — это traceroute, который не делает один проход и останавливается, а гоняет пробы по кругу, накапливая по каждому хопу статистику: потери, min/avg/max RTT, джиттер. Сетевики живут в нём, потому что «ping says OK but feels slow» — это как раз про потери на промежуточном хопе, которые видны только в динамике.

Первая наивная версия делала пробы последовательно: проба на TTL=1, ждём ответ или таймаут, TTL=2, ждём… На 12 хопах, где пара роутеров режет ICMP rate-limit, один цикл занимал 5-7 секунд. Пользователь ставит интервал «1 секунда», а счётчик циклов тикает раз в пять. Так не пойдёт.

Решение — слать все пробы цикла разом, а ответы собирать в одном окне. Хитрость в том, как сопоставить пришедший ICMP-ответ с конкретным TTL. Я даю каждой пробе уникальный UDP-порт назначения (base + ttl + cycle_offset). Когда роутер возвращает ICMP Time Exceeded, он прикладывает первые байты исходного пакета — включая UDP-заголовок с этим портом. Демультиплексируем по нему:

/// Pull the destination port out of the UDP header embedded in an ICMP/// Time-Exceeded error so we know which outstanding probe this reply is for.static func extractInnerDestPort(from data: Data) -> UInt16? {    let bytes = Array(data)    let icmpHeader = 8    guard bytes.count > icmpHeader else { return nil }    let ihlBytes = Int(bytes[icmpHeader] & 0x0F) * 4   // inner IP header length    guard ihlBytes >= 20 else { return nil }    let innerUDP = icmpHeader + ihlBytes    guard bytes.count >= innerUDP + 4 else { return nil }    return (UInt16(bytes[innerUDP + 2]) << 8) | UInt16(bytes[innerUDP + 3])}

Теперь время цикла ≈ probeTimeout, а не сумма ожиданий по всем хопам.

Второй нюанс — статистика. Считать stddev «в лоб» (хранить все RTT и пересчитывать) для бесконечно идущего инструмента — плохая идея: память течёт линейно. Берём онлайн-алгоритм Уэлфорда — он обновляет среднее и дисперсию за O(1) на отсчёт и численно стабилен:

mutating func recordReply(rttMs: Double) {    recv += 1    lastMs = rttMs    bestMs = bestMs.map { min($0, rttMs) } ?? rttMs    worstMs = worstMs.map { max($0, rttMs) } ?? rttMs    // Welford: running mean + M2    let delta = rttMs - mean    mean += delta / Double(recv)    m2 += delta * (rttMs - mean)}var stdDevMs: Double? {    guard recv >= 2 else { return nil }    return (m2 / Double(recv - 1)).squareRoot()}

Отдельная засада была с UI: в первом цикле длину пути ещё не знаем, поэтому шлём пробы на все 30 TTL. Но destination отвечает Port Unreachable на каждый TTL ≥ реального (пакет успевает дойти) — и таблица распухала до 30 строк с одинаковым IP. Лечится тем, что после первого «финального» ответа запоминаем минимальный TTL до цели и обрезаем хвост.


2. Path MTU Discovery — почему «очевидный» способ не сработал

Path MTU — это максимальный размер пакета, который проходит до цели без фрагментации. Классика для дебага VPN и туннелей: если у тебя WireGuard режет MTU до 1420, а приложение шлёт 1500 с DF-флагом, то пакеты молча дропаются, и «интернет работает, но половина сайтов не грузится».

Алгоритм понятный — бинарный поиск: шлём пакет с выставленным Don’t Fragment и растущим payload, ловим, где начинает прилетать ICMP «Fragmentation Needed». Первая версия слала UDP на случайный высокий порт, ожидая Port Unreachable как сигнал «дошло».

На тесте против 1.1.1.1 я получил Path MTU = 688 байт. Что для любой современной сети бред. Причина: Cloudflare (и почти любой нормальный публичный хост) молча фильтрует UDP на случайные порты — Port Unreachable не приходит никогда. Алгоритм трактовал каждый таймаут как «слишком большой пакет» и сходился к мусору.

Переписал на ICMP Echo с DF. Публичные хосты отвечают на ping надёжно, и сигнал «прошло» стал настоящим:

let payload = Data(repeating: 0x40, count: size - 28)  // 20 IP + 8 ICMPlet packet = ICMPHeader.echoRequest(identifier: 0, sequence: seq, payload: payload)icmpSock.setDontFragment(true)try icmpSock.send(data: packet, to: address)

А когда роутер возвращает Frag-Needed, он по RFC 1191 кладёт в пакет свой next-hop MTU — можно прыгнуть сразу на нужное значение, не досматривая бинарный поиск:

/// Next-Hop MTU lives in bytes [6..7] of an ICMP Frag-Needed packet (RFC 1191).static func extractNextHopMTU(from data: Data) -> Int? {    let bytes = Array(data)    guard bytes.count >= 8 else { return nil }    let mtu = (UInt16(bytes[6]) << 8) | UInt16(bytes[7])    return mtu > 0 ? Int(mtu) : nil}

Бонус: по найденному значению можно угадать тип канала. 1492 → PPPoE, 1480 → GRE, 1420-1440 → WireGuard, 1400 → OpenVPN/IPSec. В приложении это выводится подсказкой («вероятно, PPPoE»), что для непрофи объясняет, откуда взялась цифра.


3. Site Reach и детект блокировок по SNI

Инструмент проверяет доступность списка популярных сайтов. Сначала была наивная версия: TCP-connect на 443, прошёл — «доступен». Но это не ловит главный тип современных блокировок.

DPI на национальном/корпоративном уровне часто работает так: TCP-хендшейк проходит полностью, и только увидев запрещённый hostname в SNI внутри TLS Client Hello, DPI инжектит RST. С точки зрения устройства TCP-соединение установилось — то есть TCP-only проба покажет «доступно», хотя HTTPS не работает.

Чтобы это поймать, надо довести до TLS-хендшейка. Делаю два пробинга и сравниваю:

TCP

TLS

Вывод

fail

fail

IP-блокировка (или DNS в мёртвый IP)

ok

fail

SNI / TLS-блокировка — DPI отбил Client Hello по hostname

ok

ok

реально доступен

TLS-проба через NWConnection с NWProtocolTLS — она сама выставляет SNI в hostname соединения, что и триггерит SNI-aware DPI. В UI такие сайты получают отдельный бейдж «SNI», чтобы не путать с обычной недоступностью.


Остальные шесть — коротко

  • TLS Inspector — пробит сервер по всем версиям TLS 1.0–1.3 параллельно (TaskGroup, по одному NWConnection на версию с pin’ом min/max протокола), показывает согласованный cipher и помечает устаревшие 1.0/1.1 и слабые шифры (RC4, 3DES).

  • Encrypted DNS — резолв одного домена через DoH и DoT у Cloudflare/Google/Quad9/ NextDNS, сравнение ответов и латентности. Свой минимальный DNS wire-codec.

  • STUN / NAT — настоящий RFC 5389 Binding Request к нескольким серверам, разбор XOR-Mapped-Address, классификация NAT (Open / Cone / Symmetric) по тому, совпадают ли внешние порты. Полезно для «почему не работают P2P-звонки».

  • Bonjour browserNWBrowser по ~20 типам mDNS-сервисов (AirPlay, Chromecast, HomeKit, принтеры), с разбором TXT-записей.

  • HTTP/3 (QUIC) — реальный QUIC-хендшейк через NWConnection + NWProtocolQUIC с ALPN «h3» на UDP/443. URLSession сам на QUIC не апгрейдится без кэша Alt-Svc, так что это единственный способ честно проверить, пропускает ли сеть QUIC.

  • IPv6 Check — локальные v6-адреса с распознаванием туннелей (utun → iCloud Private Relay / NAT66), детект NAT64/DNS64 по RFC 7050, сравнение v4 vs v6 латентности.


Чему научился

  1. «Очевидная» проба часто врёт. UDP на случайный порт (Path MTU), TCP-only для проверки доступности (Site Reach) — в реальной сети дают ложный результат. Достоверный ответ даёт только настоящий протокольный обмен: ICMP Echo с DF, полный TLS-хендшейк.

  2. Сырые ICMP/UDP-пакеты на iOS доступнее, чем кажется — через SOCK_DGRAM ICMP-сокет без entitlements можно собрать ping, traceroute, MTR, Path MTU. Но raw SYN, ARP, packet capture по-прежнему закрыты.

  3. Тестируй на двух сетях. Половину багов поймал, прогоняя на чистой европейской сети vs российской сотовой с CGNAT/symmetric NAT — поведение кардинально разное, и ложные «блокировки» вылезли именно там.

  4. Network.framework — правильный инструмент для TLS/QUIC. Там, где для ICMP идёшь в BSD-сокеты, для TLS Inspector и HTTP/3 удобнее NWConnection: пин версии TLS, ALPN, реальный QUIC-хендшейк на UDP/443 — всё из коробки, без сторонних библиотек.

Все инструменты по-прежнему работают без приватных entitlements и не собирают данные — результаты остаются на устройстве.

NetDiag+ в App Store: https://apps.apple.com/app/id6761954529 (теперь 12 языков, 25 инструментов)

Вопросы и замечания по реализации — велкам в комментарии, отвечу.

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