BBC NewsWatch: Match of the DayESPN DeportesClaves de la victoria del Real Madrid vs Barcelona en el ClásicoESPNAntonelli wins US GP but made to wait for titleDaily MaverickJSC’s October 2026 interviews reveal leadership and judicial appointment challengesInquirerEco concerns raised over land conversion in Tarlac Aeta communitiesColliderBruce Campbell's 27-Episode Sci-Fi Western Deserves a Second ChanceFootball ItaliaPulisic ‘job’ when Modric has the ball, Hutchinson gift after ‘massive’ first Milan goalRai NewsSerie A, Serie A, Udinese-Milan 0-2: i rossoneri agganciano la terza piazza e la RomaCNN بالعربيةالكلاسيكو.. فينيسيوس جونيور يعلق بسخرية من خسارة برشلونة أمام ريال مدريدNOS SportVerstappen kan niet genieten van derde plek in Texas: 'Ging weinig goed'EngadgetDoes TSA actually allow AirTags in your luggage? Here's what the rules sayCBS SportsChristian McCaffrey injury: 49ers' RB says leg 'just died' on him during loss to Falcons
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

Ping, traceroute и Path MTU на Android без root и NDK

Translate

Когда я садился портировать своё iOS-приложение на Android, я был уверен, что ping, traceroute и Path MTU там не сделать. ICMP — это raw-сокет, raw-сокет — это root, а без root я видел два варианта: дёргать /system/bin/ping или мерить TCP-подключение и называть это пингом. Я уже начал продумывать замену на TCP-порты — и тут задал себе простой вопрос: у ICMP нет портов, так почему я строю пинг вокруг них? Ответа не нашлось, пришлось проверить:

$ adb shell cat /proc/sys/net/ipv4/ping_group_range
0	2147483647

Одна строка — и план меняется. На всех Android-устройствах, где я это проверял, любое приложение может открыть непривилегированный ICMP-сокет. Без root и без единой строчки на C.

Это пятая статья про NetDiag+ — утилиту сетевой диагностики, которую я пишу один. Первые четыре были про iOS: ping и traceroute без entitlements, MTR и Path MTU, замер задержки без root и тихий 403 от Cloudflare в тесте bufferbloat. Ниже — как на Android устроены ping, traceroute, MTR и Path MTU на чистом Kotlin и где всё это ломается.

Ping-сокеты: ICMP без raw-сокета

В Linux с версии 3.0 есть «ping-сокеты»: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP). Отправляешь ICMP echo request как полезную нагрузку датаграммы — ядро возвращает echo reply. Кому можно открывать такой сокет, решает sysctl net.ipv4.ping_group_range. По умолчанию в ядре он закрыт (1 0), а на Android-устройствах, которые я проверял, открыт для всех групп. Каждое приложение в Android работает под своим UID, поэтому такой сокет может открыть любое приложение.

Чтобы обойтись без NDK, хватает android.system.Os:

val fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_ICMP)
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
                     StructTimeval.fromMillis(timeoutMs))
Os.sendto(fd, packet, 0, packet.size, 0, address, 0)
val n = Os.recvfrom(fd, buf, 0, buf.size, 0, null)   // по таймауту бросает ErrnoException(EAGAIN)

Пакет — обычный 8-байтный заголовок ICMP плюс данные. От raw-сокета два отличия:

  • Идентификатор назначает ядро. У ping-сокета свой идентификатор, как у UDP-сокета свой порт, и ядро само вписывает его в каждый запрос. Поэтому поле я оставляю нулевым, а ответы сопоставляю по номеру последовательности.

  • Ответы фильтрует ядро. Сокет получает только echo reply со своим идентификатором. Два пинга, запущенные одновременно, чужих ответов не видят.

Вот здесь Android и правда отличается от iOS. На iPhone я использую такой же SOCK_DGRAM-сокет, но Darwin доставлял echo reply и другим ICMP-сокетам того же приложения. Когда я добавил второй пингер, все строки пинга показали 0 мс, пока я не начал фильтровать по идентификатору сам, — об этом была одна из прошлых статей. На Android эту фильтрацию делает ядро.

Таймаут на приём я задаю через setsockoptTimeval, а этот метод появился в API 29 — отсюда minSdk 29. Без таймаута потерянный ответ блокирует поток навсегда. На более старых версиях ту же работу должен сделать Os.poll перед recvfrom, но опускаться ниже 29 мне не понадобилось.

Traceroute: хопы приходят в очередь ошибок

Traceroute шлёт echo request с растущим TTL. Роутер, на котором у пакета кончился TTL, отвечает ICMP Time Exceeded. С raw-сокетом эти ответы просто читаешь. С ping-сокетом — нет: Linux считает их ошибками сокета, и сам пакет recvfrom() не вернёт.

Чтобы их увидеть, нужно включить IP_RECVERR и читать очередь ошибок через recvmsg(MSG_ERRQUEUE). Каждая ошибка приходит в control-сообщении со структурой sock_extended_err, а за ней — адрес роутера, который ответил.

В android.system.Os есть recvmsg, StructMsghdr и StructCmsghdr (API 31), но нет констант: IP_RECVERR и MSG_ERRQUEUE в OsConstants отсутствуют, поэтому передаю сырые значения Linux:

const val IP_RECVERR = 11
const val MSG_ERRQUEUE = 0x2000

Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, IP_RECVERR, 1)
Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, OsConstants.IP_TTL, ttl)

// после отправки, если echo reply не пришёл:
val iov = arrayOf(ByteBuffer.allocate(1500))
val control = arrayOf(StructCmsghdr(OsConstants.IPPROTO_IP, IP_RECVERR, ByteArray(512)))
val msg = StructMsghdr(null, iov, control, 0)
Os.recvmsg(fd, msg, MSG_ERRQUEUE)   // EAGAIN, если очередь пуста
for (cmsg in msg.msg_control ?: emptyArray()) {
    if (cmsg.cmsg_level != OsConstants.IPPROTO_IP || cmsg.cmsg_type != IP_RECVERR) continue
    val d = cmsg.cmsg_data ?: continue
    if (d.size < 24) continue
    if ((d[4].toInt() and 0xFF) != 2) continue          // ee_origin == SO_EE_ORIGIN_ICMP
    val type = d[5].toInt() and 0xFF                     // ee_type: 11 Time Exceeded, 3 Unreachable
    if (type != 11 && type != 3) continue
    val hop = (0 until 4).joinToString(".") { (d[20 + it].toInt() and 0xFF).toString() }
    // hop — роутер, который ответил
}

Смещения — из sock_extended_err: origin в байте 4, тип ICMP в байте 5, с 16-го байта идёт sockaddr_in отправителя, так что его IPv4-адрес лежит в байтах 20–23. Проверки cmsg_level и ee_origin выглядят как паранойя, но без них постороннее control-сообщение или локальная ошибка прочитались бы по тем же смещениям и могли бы дать правдоподобный, но выдуманный хоп.

Сам адресат ошибку не шлёт, а отвечает обычным echo reply — по нему цикл и понимает, что дошёл. MTR — тот же цикл, только повторяемый по кругу, со статистикой потерь и задержки по каждому хопу.

Path MTU: бит DF без raw-сокета

Path MTU — самый большой пакет, который проходит весь путь без фрагментации. VPN, PPPoE и некоторые мобильные сети делают его меньше 1500. Само по себе это не беда, но если где-то по пути режут ICMP Fragmentation Needed, получается классика: одни сайты открываются, другие висят.

Метод — echo request с битом don’t fragment и двоичный поиск по размеру. Ping-сокет умеет и это:

const val IP_MTU_DISCOVER = 10
const val IP_PMTUDISC_DO = 2
Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO)

С IP_PMTUDISC_DO ядро ставит DF на каждый пакет и отказывается отправлять пакет больше известного ему MTU: sendto() падает с EMSGSIZE. Отсюда правило: пришёл ответ — размер проходит; ошибка отправки или таймаут — не проходит. Размеры считаются для всего IP-пакета, так что данные в ICMP — это размер минус 28 байт заголовков. Поиск идёт от 552 до 1500 байт, это около десяти проб.

Одно сделать не вышло: прочитать оценку ядра через getsockopt(IP_MTU) для сверки. В android.system.Os есть setsockoptInt, но нет getsockoptInt — только getsockoptTimeval. Это единственное место, где NDK-прослойка что-то дала бы. Двоичный поиск работает и без неё.

На чём я споткнулся

1. Мой эмулятор не пинговал интернет. В моей конфигурации сеть эмулятора пропускала TCP и UDP, но не ICMP к внешним адресам: даже ping 1.1.1.1 из adb shell ничего толкового не давал. Все ICMP-инструменты сломались разом, а все TCP-шные работали — выглядело точь-в-точь как баг в моём коде. Пинг до 10.0.2.2 (в эмуляторе это псевдоним хост-машины) дал 0% потерь: значит, сокет и сопоставление номеров в порядке. ICMP проверяйте на живом телефоне.

2. Задокументированный API, которого нет. По документации Os.recvmsg, StructMsghdr и StructCmsghdr появились в API 31. Но на физическом устройстве в Firebase Test Lab с более новым API traceroute упал с NoClassDefFoundError: класса во фреймворке просто не было. Теперь я один раз проверяю наличие классов через Class.forName и кэширую ответ. А заодно на собственном опыте узнал, что NoClassDefFoundError — это Error, а не Exception: мой catch (e: Exception) его пропустил, и приложение упало, вместо того чтобы перейти в запасной режим. В этом режиме — ниже API 31 или при неудачной проверке — traceroute прямо пишет, что может проверить только доступность адресата, а не печатает двадцать строк *, которые легко принять за фильтрующую сеть.

3. «Одни звёздочки после первого хопа» — не всегда ваш код. Однажды вечером мой traceroute показал роутер на первом хопе, а дальше сплошные *, хотя адресат отвечал. Mac за тем же роутером в ту же минуту (ping -c 1 -m <ttl>) получал Time Exceeded от большинства хопов, а через двадцать минут телефон совпал с Mac хоп в хоп. Гипотеза у меня такая. Входящую ICMP-ошибку ядро сопоставляет с ping-сокетом по идентификатору внутри вложенной копии исходного пакета. Если NAT на выходе переписывает идентификатор, а во вложенном заголовке ICMP-ошибки не возвращает его обратно, ошибка до очереди сокета не доходит. Time Exceeded от самого домашнего роутера, по-видимому, формируется ещё до трансляции — поэтому первый хоп и виден. ping на macOS сопоставляет ответы сам, в пользовательском пространстве, — это объяснило бы, почему у Mac такой картины не было. Почему всё прошло через двадцать минут, гипотеза не объясняет, так что это именно гипотеза. Но если увидите такую же картину, прогоните тот же путь с ноутбука, прежде чем отлаживать парсер.

Где это ломается

  • Только IPv4 — в моей реализации. Ping-сокеты есть и для IPv6 (IPPROTO_ICMPV6), но мои инструменты берут только IPv4-адрес хоста.

  • Фильтрующая сеть выглядит как мёртвая. Если ICMP режут по пути, Path MTU не отличит «слишком большой» от «потерян», а traceroute покажет *. Это ограничение ICMP, а не типа сокета.

  • Производитель может закрыть ping_group_range. Мне такие не попадались, но если Os.socket падает с EACCES, скорее всего, дело в этом, и честнее прямо сказать об этом в интерфейсе.

Ссылки

Аккаунтов нет. В приложении есть аналитика Firebase и реклама.

Вопрос к тем, кто писал под Android: встречали ли вы прошивку, где ping_group_range закрыт, или устройство с API 31+, где нет StructCmsghdr? Хочу собрать список производителей, чтобы приложение говорило об этом прямо, а не гадало. И если знаете, как проверить гипотезу про NAT из третьего пункта, — расскажите.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.