Preskoči na sadržaj

Inženjering5 min čitanja

Tajmer za isključenje VPN-a na iOS-u: čuvar živi u tunelu

Kako VPN Russia ograničava besplatnu sesiju na 15 minuta na iOS-u i macOS-u: prozor sesije u App Group-u, čuvar unutar NEPacketTunnelProvider-a, sleep i wake i isključenje koje On Demand ne može da poništi.

Naša aplikacija VPN Russia ima besplatni režim sa jednostavnim pravilom: 15 minuta veze, zatim 45 minuta pauze. Pretplata uklanja ograničenje. Tajmer deluje kao sitnica, ali krije pravo pitanje: koji proces njime upravlja? U nastavku je opisano kako to radi u našem kodu za iOS i macOS, sa primerima na Swift-u. Verzija za Android je u posebnom tekstu.

Zašto tajmer ne može da živi u aplikaciji

Očigledno mesto je sama aplikacija: pokrenuti Timer pri povezivanju i pozvati stopVPNTunnel() kada istekne. To puca u tri česte situacije:

  • Aplikacija ode u pozadinu i iOS je za nekoliko sekundi suspenduje. U suspendovanoj aplikaciji tajmeri ne rade.
  • Korisnik prevuče aplikaciju ili je sistem ugasi zbog memorije. Tunel i dalje radi, a tajmera više nema.
  • Tunel može da se podigne i bez aplikacije: prekidačem za VPN u Podešavanjima ili po pravilima Connect On Demand.

Jedini proces koji živi tačno onoliko koliko i tunel jeste sama Network Extension ekstenzija, naslednik NEPacketTunnelProvider-a. Zato tajmer pripada ekstenziji i ona sama gasi tunel. Aplikacija samo čita isto stanje da bi nacrtala interfejs i odbila povezivanje tokom pauze.

Prozor sesije umesto odbrojavanja

Ekstenzija i aplikacija su različiti procesi, pa stanje ide u UserDefaults zajedničke App Group grupe, koju vide oba. Čuvamo dva datuma, a ne preostalo vreme:

  • allowedDate — trenutak kada se završava besplatna sesija;
  • nextReconnectDate — trenutak kada se završava pauza i može da počne nova sesija.

Datumi preživljavaju svako ponovno pokretanje oba procesa i sami daju glavno pravilo: ponovno povezivanje unutar prozora ga ne produžava. Ako se isključite u desetom minutu i ponovo povežete, ostaje vam pet minuta, a ne petnaest.

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 }

    /// Povezivanje je zabranjeno samo između kraja sesije i kraja pauze.
    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")
    }
}

Niko neće čekati četvrt sata da bi proverio tajmer, pa debug verzija koristi kraće vrednosti:

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

Pokretanje čuvara u startTunnel

Čuvar se pokreće kada je tunel zaista podignut — odmah posle uspešnog setTunnelNetworkSettings. Pretplatnike ne dira: Archive.isPremium se čita iz iste App Group grupe.

PacketTunnelProvider.swiftswift
// unutar startTunnel, posle primene mrežnih podešavanja
try await setTunnelNetworkSettings(settings)
addConnectionObserver()

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

private func perform() {
    // Prvo povezivanje otvara prozor
    if ConnectionValidator.allowedDate == nil {
        ConnectionValidator.newConnection()
    }
    guard ConnectionValidator.canConnect else {
        // Podignut tokom pauze (On Demand, prekidač u Podešavanjima): gasimo odmah
        disconnect()
        return
    }
    guard let allowedDate = ConnectionValidator.allowedDate,
          let nextReconnectDate = ConnectionValidator.nextReconnectDate else { return }

    if nextReconnectDate < .now {
        // Stari prozor i njegova pauza su prošli: počinje nova sesija
        ConnectionValidator.resetConnection()
        perform()
    } else {
        startDisconnectTimer(allowedDate: allowedDate)
    }
}

Obratite pažnju: prozor otvara sama ekstenzija. Aplikacija možda uopšte ne učestvuje u pokretanju, a ograničenje mora da radi.

Tajmer koji preživljava spavanje uređaja

Prva ideja je jedan tajmer za ceo ostatak: deadline: .now() + remaining. Nemojte. DispatchTime broji vreme rada uređaja (uptime), a ono stoji dok uređaj spava. Zaključajte iPhone na sat vremena i tajmer postavljen na deset minuta okinuće posle deset minuta budnog rada — sesija će tiho porasti za celo vreme spavanja.

Umesto toga, tajmer otkucava svakih pet sekundi i poredi kalendarski sat sa allowedDate. Otkucaji mogu da plivaju, poređenje ne može.

PacketTunnelProvider.swiftswift
private var disconnectTimer: DispatchSourceTimer?

private func startDisconnectTimer(allowedDate: Date) {
    // Proces ekstenzije može biti ponovo iskorišćen: dva tajmera istovremeno nisu dozvoljena
    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
}

Tolerancija od jedne sekunde (leeway) dozvoljava sistemu da grupiše buđenja — za ekstenziju koja radi satima to se vidi na bateriji.

NEPacketTunnelProvider uz to javlja kada uređaj zaspi i kada se probudi. Oba trenutka su dobra prilika da se proveri rok bez čekanja sledećeg otkucaja:

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()
    }
}

Isključiti tako da ostane isključeno

Zaustaviti VPN jezgro ili pozvati cancelTunnelWithError nije dovoljno: profil ostaje uključen, i ako ima pravila Connect On Demand, iOS odmah ponovo podiže tunel. Zato isključujemo konfiguraciju. Prekidač za VPN u Podešavanjima se gasi, sistem zaustavlja tunel sa razlogom configurationDisabled i ne pokreće ga ponovo.

PacketTunnelProvider.swiftswift
private func disconnect() {
    // Sistem će prijaviti sopstveni razlog zaustavljanja; pamtimo
    // da je u pitanju bilo ograničenje besplatne sesije, zbog analitike
    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()

        // Ako je pokretanje još u toku, sačekamo ga pre zaustavljanja jezgra
        await waitForStartToFinish()
        stopActiveCore(context: "disconnect()")
    }
}

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

Upozorenje

isEnabled = false isključuje VPN profil, pa sledeće povezivanje iz aplikacije mora ponovo da ga uključi pre poziva startVPNTunnel(). Naš connect() proverava manager.isEnabled i, ako je profil isključen, postavlja true i prvo čuva podešavanja.

Dva detalja iz produkcije. Prvo: otkažite tajmer u stopTunnel. Korisnik može da isključi VPN sekundu pre nego što tajmer okine, a zakasneli disconnect() napraviće trku sa običnim zaustavljanjem. Drugo: zapišite zašto se sesija završila. Sistem prijavljuje zaustavljanje kao isključenu konfiguraciju, i bez sopstvene oznake analitika ne može da razlikuje ograničenje besplatne sesije od korisnika koji je sam isključio VPN u Podešavanjima.

Strana aplikacije: odbiti i objasniti

Aplikacija ne gasi tunel zbog ograničenja — to radi ekstenzija. Ali pre povezivanja proverava isti prozor i tokom pauze pokazuje koliko još treba čekati, umesto da podiže tunel koji bi odmah bio ugašen:

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))
    }
}

Kontrolna lista

  • Tajmer pripada ekstenziji tunela, a ne aplikaciji.
  • Čuvajte kraj sesije i kraj pauze kao datume u App Group-u, a ne preostalo vreme.
  • Otkucavajte često i poredite sa kalendarskim satom: jedan dugi tajmer na DispatchTime rasteže se za vreme spavanja.
  • Proveravajte rok i u sleep() i wake().
  • Isključujte tako što isključite konfiguraciju, inače će vas On Demand ponovo povezati.
  • Otkažite tajmer u stopTunnel i pre pravljenja novog.
  • Pretplatnicima se prozor ne otvara: posle otkazivanja pretplate neće naleteti na staro ograničenje.
  • U debug verziji koristite kratke intervale.

Ista logika na Androidu — sa VpnService-om, DataStore-om i korutinama umesto ekstenzije i dispatch tajmera — nalazi se u tekstu o Androidu.

PodeliXLinkedIn

ONLYAPPS Team

Novi Sad, Srbija · onlyapps.org

Kontakt

Kolačiće za analitiku koristimo samo uz vašu saglasnost. Neophodni kolačići održavaju rad sajta. Politika privatnosti