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

Когда я садился портировать своё 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, скорее всего, дело в этом, и честнее прямо сказать об этом в интерфейсе.
Ссылки
NetDiag+ в Google Play: https://play.google.com/store/apps/details?id=com.netdiag.android&referrer=utm_source%3Dhabr%26utm_medium%3Darticle
NetDiag+ в App Store: https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&ct=habr&mt=8
Если Path MTU вышел меньше 1500 — где менять MTU на популярных роутерах: https://netdiag.online/ru/guides/router-mtu/
Аккаунтов нет. В приложении есть аналитика Firebase и реклама.
Вопрос к тем, кто писал под Android: встречали ли вы прошивку, где ping_group_range закрыт, или устройство с API 31+, где нет StructCmsghdr? Хочу собрать список производителей, чтобы приложение говорило об этом прямо, а не гадало. И если знаете, как проверить гипотезу про NAT из третьего пункта, — расскажите.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.