В апреле я выложил в 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 browser —
NWBrowserпо ~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 латентности.
Чему научился
-
«Очевидная» проба часто врёт. UDP на случайный порт (Path MTU), TCP-only для проверки доступности (Site Reach) — в реальной сети дают ложный результат. Достоверный ответ даёт только настоящий протокольный обмен: ICMP Echo с DF, полный TLS-хендшейк.
-
Сырые ICMP/UDP-пакеты на iOS доступнее, чем кажется — через
SOCK_DGRAMICMP-сокет без entitlements можно собрать ping, traceroute, MTR, Path MTU. Но raw SYN, ARP, packet capture по-прежнему закрыты. -
Тестируй на двух сетях. Половину багов поймал, прогоняя на чистой европейской сети vs российской сотовой с CGNAT/symmetric NAT — поведение кардинально разное, и ложные «блокировки» вылезли именно там.
-
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/