Как я делал ping и traceroute на iOS без entitlements — и почему это оказалось проще, чем UMP-консент для AdMob

от автора

Я выпустил небольшое iOS-приложение — NetDiag+. Это набор сетевых утилит: ping, traceroute, DNS lookup, whois, LAN-сканер, port scanner, проверка SSL-сертификатов, BGP/ASN lookup, Wi-Fi info и фоновый мониторинг хостов с пушами при падении. Я начинал его как пет-проект для собственных нужд, потому что на iOS приходилось переключаться между четырьмя разными приложениями для базовой диагностики, и в трёх из четырёх была реклама.

Хочу поделиться тем, что мне самому хотелось бы прочитать в начале — почему некоторые вещи на iOS работают не так, как ожидаешь от Unix-фона, и где грабли лежат не там, где кажется.

Сразу спойлер по выводам: самым болезненным оказалось не сетевое программирование, а интеграция UMP-консента для AdMob.

Стек

SwiftUI, iOS 16+, чистый Swift Concurrency (async/await, AsyncStream, TaskGroup), Darwin C-API там где деваться некуда. Никаких сторонних сетевых библиотек — всё на голых BSD-сокетах через C-интероп.


ICMP ping без entitlement

Первое, что удивило: на iOS можно открыть ICMP-сокет без специальных entitlements и без рута, чего нельзя сделать на классическом Linux без CAP_NET_RAW или setuid. Apple сделала эту возможность доступной для обычных пользовательских процессов через SOCK_DGRAM (а не SOCK_RAW):

final class ICMPSocket {    private let fd: Int32    init() throws {        fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)        guard fd >= 0 else {            throw SocketError.creationFailed(errno)        }    }    deinit {        close(fd)    }    // ...}

Это та же модель, что использует macOS-овский ping(8) без setuid начиная с какой-то из старых версий macOS. Ядро само пишет правильный ICMP-header за тебя (тип/код/checksum для echo request), а identifier подменяет на сгенерированный — поэтому на recv ты получаешь не тот ID, что отправлял, и стандартная логика matching по identifier не работает. Это первая грабля.

Решение — matching по sequence number и payload. Я кладу свой маркер в payload и проверяю его на приёме:

// Sendvar header = ICMPHeader()header.type = ICMPType.echoRequest.rawValueheader.code = 0header.identifier = 0   // ядро перепишетheader.sequence = currentSequence.bigEndianlet payload = makePayload(sequence: currentSequence)let packet = header.bytes + payloadtry socket.send(data: packet, to: address)// Receivelet (data, fromIP) = try socket.receive()guard let received = ICMPHeader.parse(data),      received.type == ICMPType.echoReply.rawValue,      received.sequence == currentSequence.bigEndianelse { continue }

Вторая грабля — таймауты. recvfrom без SO_RCVTIMEO блокируется навсегда, что в Swift Concurrency означает зависший Task, который потом нельзя нормально отменить. Я ставлю таймаут на сокет:

func setTimeout(seconds: Double) {    var tv = timeval(        tv_sec: Int(seconds),        tv_usec: Int32((seconds.truncatingRemainder(dividingBy: 1)) * 1_000_000)    )    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))}

И на EAGAIN/EWOULDBLOCK бросаю свой SocketError.timeout, который выше по стеку превращается в нормальный пинговый “timeout” hop.

Apple когда-то выложила пример SimplePing — это OG-референс на Objective-C, у которого многое можно подсмотреть, но он плохо ложится в современный Swift Concurrency и неудобен для multi-host пингов. Я в итоге написал свою тонкую обёртку и не жалею.


Traceroute, который реально работает

Стандартный Unix-traceroute может работать тремя способами: ICMP echo (как Windows tracert), UDP к закрытым портам (классический BSD-вариант) или TCP SYN (paris/traceroute-tcp).

На iOS я попробовал ICMP-варинт первым. Идея простая: отправляешь ICMP echo с маленьким TTL, ждёшь ICMP Time Exceeded от промежуточного хопа, увеличиваешь TTL. На бумаге всё хорошо. На практике у меня были две проблемы:

  1. Setsockopt(IP_TTL) на SOCK_DGRAM ICMP-сокете работает капризно — у меня не все хопы возвращали Time Exceeded, поведение было неконсистентным между разными сетями.

  2. Matching входящих ответов — на receive ты получаешь обратно ICMP Time Exceeded, внутри которого лежит твой исходный пакет. Чтобы сопоставить ответ конкретному TTL-шагу, нужно парсить inner-payload, что мне показалось хрупко.

Поэтому я переключился на классический BSD-подход: UDP-пакеты на закрытые порты с увеличивающимся TTL, и параллельно слушаем ICMP-сокет на входящие Time Exceeded:

let icmpSock = try ICMPSocket()  // только для приёмаicmpSock.setTimeout(seconds: timeout)for ttl in 1...maxHops {    let udpSock = try UDPSocket()    udpSock.setTTL(ttl)    var dest = address    dest.sin_port = (port + UInt16(ttl - 1)).bigEndian  // 33434, 33435, ...    let probe = Data(repeating: 0x40, count: 32)    let sendTime = CFAbsoluteTimeGetCurrent()    try udpSock.send(data: probe, to: dest)    let (data, fromIP) = try icmpSock.receive()    let rtt = CFAbsoluteTimeGetCurrent() - sendTime    // ...}

Порты 33434+ttl — это исторический выбор traceroute(8) ещё с 80-х: маловероятно, что они открыты на конечном хосте, поэтому ты гарантированно получаешь либо ICMP Time Exceeded от промежуточного хопа, либо ICMP Port Unreachable от destination — оба варианта обрабатываются единообразно.

Что важно понимать: на iOS UDP_TTL отлично работает через setsockopt(IP_TTL), в отличие от ICMP-варианта. И ICMP-сокет используется только как пассивный приёмник — мы из него ничего не шлём, только читаем входящие Time Exceeded.

Финальный хоп определяю по двум условиям:

let isFinal = fromIP == destIP    || header.type == ICMPType.destinationUnreachable.rawValue

То есть либо ответ пришёл от целевого IP (например если сам destination прислал ICMP Port Unreachable), либо тип ICMP — Destination Unreachable.


LAN-сканер: ARP недоступен

Здесь iOS режет жёстче. Тебе не отдают ARP-таблицу системы в userspace. Нет API типа getarp(), нет /proc/net/arp как на Linux. Поэтому забудьте про идею “просто прочитать ARP-таблицу и показать, кто в локалке”.

Что доступно:

  1. Concurrent TCP connect на популярные порты (80, 443, 22, 8080 и т.д.) — если хост ответил, он живой. Raw SYN недоступен из userspace, поэтому только полноценный TCP handshake.

  2. mDNS / Bonjour discovery через NetServiceBrowser или NWBrowser — но это не полная картина, видны только устройства, которые сами анонсируют сервисы.

  3. NSLocalNetworkUsageDescription в Info.plist — обязательно, иначе при первой попытке коннекта на локальный IP iOS откроет alert “Allow Local Network Access”, и до ответа пользователя ничего не работает.

Я пошёл по первому пути — параллельный TCP connect через withThrowingTaskGroup:

private static let maxConcurrentProbes = 20func discoverHosts(localIP: String, subnetMask: String) -> AsyncThrowingStream<LANDevice, Error> {    AsyncThrowingStream { continuation in        Task.detached {            let range = Self.calculateSubnetRange(ip: localIP, mask: subnetMask)            try await withThrowingTaskGroup(of: LANDevice?.self) { group in                var activeCount = 0                for ip in range {                    if activeCount >= Self.maxConcurrentProbes {                        if let device = try? await group.next() {                            if let d = device { continuation.yield(d) }                        }                        activeCount -= 1                    }                    group.addTask {                        try await Self.probeHost(ip: ip)                    }                    activeCount += 1                }                for try await device in group {                    if let d = device { continuation.yield(d) }                }            }            continuation.finish()        }    }}

Один connect() на порт 80 с таймаутом 500мс на каждый IP в /24 — хост либо ответит SYN+ACK (живой), либо RST (живой, но порт закрыт — тоже считаю живым), либо таймаут (мёртвый или фильтрует). За счёт maxConcurrentProbes = 20 весь /24 пробегается за пару секунд.

Что я не делаю, но мог бы:

  • Не использую mDNS для дополнения списка (есть в TODO)

  • Не делаю port scanning по умолчанию — это уже отдельная функция в приложении

Грабля, на которую я наступил: на iOS 17 alert “Local Network Access” показывается только один раз. Если пользователь отказал — следующая попытка connect() молча таймаутится, без явной ошибки. Пришлось делать UI-подсказку про настройки.


Host Monitor: BGTaskScheduler как он есть

Самая инженерно неприятная часть приложения. Пользователь хочет: “добавляю хост, и если он упадёт — мне приходит пуш, как у UptimeRobot”. На iOS это нельзя сделать “правильно”, потому что:

  1. Нет background-thread, который висит постоянно — iOS убивает приложение через ~30 секунд после ухода в background.

  2. Silent push как механизм wake — есть, но требует серверной части, а у меня всё локальное.

  3. BGTaskScheduler — даёт ~30 секунд CPU может быть раз в 15+ минут. iOS сам решает, когда тебя запустить, на основе паттернов использования.

То есть точность мониторинга на iOS принципиально хуже, чем на сервере. Это надо честно признать и сделать дизайн исходя из этого.

Что я делаю:

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.netdiag.hostmonitor", using: nil) { task in    Task {        await HostMonitorService.shared.runMonitoringPass()        task.setTaskCompleted(success: true)    }    submitNext()  // переподаём задачу на следующее выполнение}let request = BGAppRefreshTaskRequest(identifier: "com.netdiag.hostmonitor")request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // не раньше чем через 15 минtry BGTaskScheduler.shared.submit(request)

И внутри runMonitoringPass():

  1. Беру все мониторируемые хосты

  2. Пингую их параллельно (withTaskGroup) — успеть в 30-секундный бюджет

  3. Для каждого: если состояние изменилось (up→down или down→up), шлю local notification

  4. Если состояние не изменилось — молчу, чтобы не спамить

Никаких пушей “хост по-прежнему up каждые 5 минут”, только state transitions. Это и битву с iOS background-лимитами помогает выиграть, и пользователю не надоедает.

Грабля: BGTaskScheduler не работает в симуляторе. Точнее, работает только если ты руками дёрнешь его через debugger:

e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.netdiag.hostmonitor"]

Я нашёл это в каком-то DTS-thread, и без этой команды отлаживать background-логику невозможно. На реальном устройстве система запускает таски, но непредсказуемо.


AdMob и UMP — то, что бесило больше всего

Сетевая часть писалась с удовольствием. Интеграция AdMob — отдельная история.

Сам AdMob через GoogleMobileAds Swift Package заводится без боли. MobileAds.shared.start() — и баннеры работают через BannerView в UIViewRepresentable. Стандартно.

Проблемы начинаются с GDPR / CCPA консента, который Google требует через свой UMP SDK (GoogleUserMessagingPlatform). У этого SDK две беды:

  1. Документация для SwiftUI — никакой. Все примеры на Objective-C или старом UIKit. Что куда подключать в SwiftUI-приложении — додумывал сам.

  2. AdMob не активирует privacy message, пока в приложении нет “revocation link” — то есть кнопки в Settings, которая открывает форму повторного согласия. Логика бизнес-процесса такая: сначала ты в коде делаешь revocation link, выкатываешь сборку, потом возвращаешься в AdMob и подтверждаешь “да, у меня revocation link есть, активируйте сообщение”. Об этом нигде явно не сказано — я неделю сидел с настроенным, но неактивным consent message, пока не понял.

В коде это выглядит так:

@MainActorfinal class ConsentManager: ObservableObject {    static let shared = ConsentManager()    @Published private(set) var adsStarted = false    @Published private(set) var privacyOptionsRequired = false    func requestConsentAndStartAds() {        let parameters = RequestParameters()        ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { [weak self] error in            ConsentForm.loadAndPresentIfRequired(from: Self.topViewController()) { [weak self] formError in                self?.updatePrivacyOptionsAvailability()                self?.startAdsIfNeeded()            }        }    }    func presentPrivacyOptionsForm() {        guard let root = Self.topViewController() else { return }        ConsentForm.presentPrivacyOptionsForm(from: root) { [weak self] error in            self?.updatePrivacyOptionsAvailability()        }    }    private func startAdsIfNeeded() {        guard ConsentInformation.shared.canRequestAds else { return }        MobileAds.shared.start { [weak self] _ in            self?.adsStarted = true        }    }    // ...}

Ключевая часть — MobileAds.shared.start() вызывается только после того, как UMP-flow вернул canRequestAds == true. Если пользователь в EEA отказал — canRequestAds всё равно true, просто реклама будет неперсонализированная. Один и тот же flow покрывает и GDPR (EEA/UK), и US state privacy (CCPA для Калифорнии и других регулируемых штатов), потому что UMP под капотом смотрит на geo и подбирает соответствующий privacy message.

Ещё одна грабля — ATTrackingManager.requestTrackingAuthorization (ATT prompt) должен показываться до UMP-flow, потому что UMP смотрит на ATT-статус при формировании запроса:

ATTManager.requestTrackingIfNeeded {    ConsentManager.shared.requestConsentAndStartAds()}

Если делать в обратном порядке — UMP может посчитать, что трекинг разрешён, а потом ATT откажет, и получится несоответствие, которое AdMob потом может зафлажить.

Для отладки в Debug я форсирую geography:

#if DEBUGlet debugSettings = DebugSettings()debugSettings.geography = .EEA  // или .regulatedUSStateparameters.debugSettings = debugSettings#endif

Без этого на российском IP UMP-форма не показывается, и проверить flow было нельзя.


Локализация

12 языков (en, ru, de, es, pt-BR, fr, it, pl, ja и ещё три) сделал через новые Xcode String Catalogs (.xcstrings). После старых Localizable.strings это просто счастье — JSON-формат, нормальный diff в git, GUI в Xcode для перевода, автоматический pluralization. Если кто-то ещё держится за старые .strings — попробуйте мигрировать, ничего не теряете.


Числа на момент написания

App Store одобрил приложение в апреле 2026. На момент публикации этой статьи — около 30 установок (т.е. почти ноль маркетинга). Эта статья — часть маркетингового эксперимента: технический deep-dive на правильную аудиторию против попыток купить ASO.

AdMob после первой недели после approval: eCPM $1.19, match rate 85% — для утилитарного приложения, говорят, нормально для старта.


Что я бы сделал по-другому

  1. Не лениться с тестами на парсинге ICMP-хедеров. У меня сейчас тесты только на DNS resolver и whois parser, а ICMP я отлаживал на живых сетях. Stress test от 30 нормально, но один раз я починил баг с big-endian sequence через два дня после релиза, и это стыдно.

  2. Сразу делать UMP до того, как закидывать билд в TestFlight. Если интегрировать UMP после, всё переписывается — порядок инициализации AdMob другой, ATT-prompt порядок меняется и т.д.

  3. Не доверять симулятору для всего что касается background tasks, mDNS, push notifications. У меня было несколько ситуаций, когда симулятор показывал зелёный свет, а на железе — ничего не работало.


Ссылки

Если кто-то делал что-то похожее на iOS — было бы интересно сравнить решения, особенно по background-мониторингу. Пишите в комменты.

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