Перейти к содержимому

Разработка5 мин чтения

Таймер отключения VPN на iOS: сторож живёт в туннеле

Как VPN Russia ограничивает бесплатную сессию 15 минутами на iOS и macOS: окно сессии в App Group, сторож внутри NEPacketTunnelProvider, sleep и wake и отключение, которое не отменит On Demand.

В нашем приложении VPN Russia есть бесплатный режим с простым правилом: 15 минут подключения, затем 45 минут паузы. Подписка снимает ограничение. Таймер кажется пустяком, но за ним прячется настоящий вопрос: какой процесс им владеет? Ниже — как это устроено в нашем коде для iOS и macOS, с примерами на Swift. Версия для Android — в отдельной статье.

Почему таймер нельзя держать в приложении

Очевидное место — само приложение: запустить Timer при подключении и вызвать stopVPNTunnel(), когда он сработает. Это ломается в трёх частых ситуациях:

  • Приложение уходит в фон, и iOS через несколько секунд его приостанавливает. В приостановленном приложении таймеры не идут.
  • Пользователь смахивает приложение, или система выгружает его ради памяти. Туннель продолжает работать, а таймера больше нет.
  • Туннель вообще может подняться без приложения: переключателем VPN в «Настройках» или по правилам Connect On Demand.

Единственный процесс, который живёт ровно столько же, сколько туннель, — само расширение Network Extension, наследник NEPacketTunnelProvider. Поэтому таймер принадлежит расширению, и туннель оно снимает само. Приложение лишь читает то же состояние, чтобы нарисовать интерфейс и не дать подключиться во время паузы.

Окно сессии вместо обратного отсчёта

Расширение и приложение — разные процессы, поэтому состояние лежит в UserDefaults общей App Group, которую видят оба. Храним две даты, а не оставшееся время:

  • allowedDate — момент, когда заканчивается бесплатная сессия;
  • nextReconnectDate — момент, когда заканчивается пауза и можно начать новую сессию.

Даты переживают любой перезапуск обоих процессов и бесплатно дают главное правило: переподключение внутри окна его не продлевает. Отключились на десятой минуте и подключились снова — осталось пять минут, а не пятнадцать.

ConnectionValidator.swiftswift
final class ConnectionValidator {
    static var defaults: UserDefaults {
        UserDefaults(suiteName: SharedConfig.k_AppGroup) ?? .standard
    }

    static var allowedDate: Date? { defaults.object(forKey: "allowedDate") as? Date }
    static var nextReconnectDate: Date? { defaults.object(forKey: "nextReconnectDate") as? Date }

    /// Подключение запрещено только между концом сессии и концом паузы.
    static var canConnect: Bool {
        if let allowedDate, let nextReconnectDate {
            let now = Date.now
            if nextReconnectDate > now, allowedDate < now { return false }
        }
        return true
    }

    static func newConnection() {
        let allowedDate = Date.now.addingTimeInterval(TimeInterval(SharedConfig.k_FreeSessionDurationInSeconds))
        let barrierDate = Calendar.current.date(
            byAdding: .minute, value: SharedConfig.k_FreeSessionPauseInMinutes, to: allowedDate
        )
        defaults.set(allowedDate, forKey: "allowedDate")
        defaults.set(barrierDate, forKey: "nextReconnectDate")
    }

    static func resetConnection() {
        defaults.set(nil, forKey: "allowedDate")
        defaults.set(nil, forKey: "nextReconnectDate")
    }
}

Ждать четверть часа ради проверки таймера никто не будет, поэтому в отладочной сборке значения короче:

SharedConfig.swiftswift
#if DEBUG
static let k_FreeSessionPauseInMinutes = 1
static let k_FreeSessionDurationInSeconds = 120
#else
static let k_FreeSessionPauseInMinutes = 45
static let k_FreeSessionDurationInSeconds = 900
#endif

Запуск сторожа в startTunnel

Сторож стартует, когда туннель действительно поднялся, — сразу после успешного setTunnelNetworkSettings. Подписчиков он не касается: Archive.isPremium читается из той же App Group.

PacketTunnelProvider.swiftswift
// внутри startTunnel, после применения сетевых настроек
try await setTunnelNetworkSettings(settings)
addConnectionObserver()

private func addConnectionObserver() {
    guard !Archive.isPremium else { return }
    perform()
}

private func perform() {
    // Первое подключение открывает окно
    if ConnectionValidator.allowedDate == nil {
        ConnectionValidator.newConnection()
    }
    guard ConnectionValidator.canConnect else {
        // Подняли во время паузы (On Demand, переключатель в «Настройках»): снимаем сразу
        disconnect()
        return
    }
    guard let allowedDate = ConnectionValidator.allowedDate,
          let nextReconnectDate = ConnectionValidator.nextReconnectDate else { return }

    if nextReconnectDate < .now {
        // Старое окно и его пауза закончились: начинаем новую сессию
        ConnectionValidator.resetConnection()
        perform()
    } else {
        startDisconnectTimer(allowedDate: allowedDate)
    }
}

Обратите внимание: окно открывает само расширение. Приложение может вообще не участвовать в запуске, а ограничение должно работать.

Таймер, который переживает сон устройства

Первая идея — один таймер на весь остаток: deadline: .now() + remaining. Не надо. DispatchTime считает время работы устройства (uptime), а оно стоит, пока устройство спит. Заблокируйте iPhone на час — и таймер, заведённый на десять минут, сработает через десять минут бодрствования: сессия молча вырастет на всё время сна.

Вместо этого таймер тикает каждые пять секунд и сравнивает календарные часы с allowedDate. Сами тики могут плыть, сравнение — нет.

PacketTunnelProvider.swiftswift
private var disconnectTimer: DispatchSourceTimer?

private func startDisconnectTimer(allowedDate: Date) {
    // Процесс расширения бывает переиспользован: два таймера одновременно недопустимы
    disconnectTimer?.cancel()

    let timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated))
    timer.schedule(deadline: .now() + 5, repeating: 5, leeway: .seconds(1))
    timer.setEventHandler { [weak self] in
        guard Date.now >= allowedDate else { return }
        self?.disconnectTimer?.cancel()
        self?.disconnectTimer = nil
        self?.disconnect()
    }
    timer.resume()
    disconnectTimer = timer
}

Допуск в одну секунду (leeway) позволяет системе группировать пробуждения — для расширения, которое работает часами, это заметно по батарее.

NEPacketTunnelProvider к тому же сообщает, когда устройство засыпает и просыпается. И то и другое — хороший момент проверить срок, не дожидаясь следующего тика:

PacketTunnelProvider.swiftswift
override func sleep(completionHandler: @escaping () -> Void) {
    if !Archive.isPremium,
       let allowedDate = ConnectionValidator.allowedDate,
       Date.now >= allowedDate {
        disconnect()
    }
    completionHandler()
}

override func wake() {
    guard !Archive.isPremium else { return }
    if let allowedDate = ConnectionValidator.allowedDate, Date.now >= allowedDate {
        disconnect()
    }
}

Отключить так, чтобы не подключилось обратно

Остановить ядро VPN или вызвать cancelTunnelWithError мало: профиль остаётся включённым, и если у него есть правила Connect On Demand, iOS тут же поднимет туннель снова. Поэтому мы выключаем конфигурацию. Переключатель VPN в «Настройках» гаснет, система останавливает туннель с причиной configurationDisabled и не перезапускает его.

PacketTunnelProvider.swiftswift
private func disconnect() {
    // Система сообщит свою причину остановки; запоминаем,
    // что это был лимит бесплатной сессии, — для аналитики
    TunnelStopRecord.markFreeLimit(in: Archive.defaults)
    Task {
        guard let provider = await NETunnelProviderManager.fetchProvider() else { return }
        provider.connection.manager.isEnabled = false
        try? await provider.connection.manager.saveToPreferences()

        // Если старт ещё в процессе, дожидаемся его, прежде чем останавливать ядро
        await waitForStartToFinish()
        stopActiveCore(context: "disconnect()")
    }
}

override func stopTunnel(with reason: NEProviderStopReason) async {
    disconnectTimer?.cancel()
    disconnectTimer = nil
    await waitForStartToFinish()
    stopActiveCore(context: "stopTunnel")
}

Внимание

isEnabled = false выключает VPN-профиль, поэтому следующее подключение из приложения должно снова его включить до вызова startVPNTunnel(). Наш connect() проверяет manager.isEnabled и, если профиль выключен, ставит true и сначала сохраняет настройки.

Две детали из боевой эксплуатации. Первая: отменяйте таймер в stopTunnel. Пользователь может выключить VPN за секунду до срабатывания, и запоздалый disconnect() устроит гонку с обычной остановкой. Вторая: записывайте, почему сессия закончилась. Система сообщает об остановке как о выключенной конфигурации, и без собственной отметки аналитика не отличит лимит бесплатной сессии от пользователя, который сам выключил VPN в «Настройках».

Сторона приложения: отказать и объяснить

Из-за лимита приложение туннель не останавливает — это делает расширение. Но перед подключением оно проверяет то же окно и во время паузы показывает, сколько осталось ждать, вместо того чтобы поднимать туннель, который тут же снимут:

ConnectView.swiftswift
if Archive.isPremium || ConnectionValidator.canConnect {
    Task { try? await vpnManager.connect() }
} else if let nextReconnectDate = ConnectionValidator.nextReconnectDate,
          nextReconnectDate > .now {
    let left = Calendar.current.dateComponents([.minute, .second], from: .now, to: nextReconnectDate)
    if let minutes = left.minute, let seconds = left.second {
        alertIdentifier = .init(choice: .limit(minutes: minutes, seconds: seconds))
    }
}

Чек-лист

  • Таймер принадлежит расширению туннеля, а не приложению.
  • Храните конец сессии и конец паузы датами в App Group, а не остаток времени.
  • Тикайте часто и сверяйтесь с календарными часами: один длинный таймер на DispatchTime растягивается на время сна.
  • Проверяйте срок и в sleep() и wake().
  • Отключайте через выключение конфигурации, иначе On Demand подключит обратно.
  • Отменяйте таймер в stopTunnel и перед созданием нового.
  • Подписчикам окно не заводится: после отмены подписки они не упрутся в старый лимит.
  • В отладочной сборке используйте короткие интервалы.

Та же логика на Android — с VpnService, DataStore и корутинами вместо расширения и dispatch-таймеров — в статье про Android.

ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности