summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/RelayCacheTracker
diff options
context:
space:
mode:
Diffstat (limited to 'ios/MullvadVPN/RelayCacheTracker')
-rw-r--r--ios/MullvadVPN/RelayCacheTracker/RelayCacheTracker.swift280
-rw-r--r--ios/MullvadVPN/RelayCacheTracker/RelayCacheTrackerObserver.swift17
2 files changed, 297 insertions, 0 deletions
diff --git a/ios/MullvadVPN/RelayCacheTracker/RelayCacheTracker.swift b/ios/MullvadVPN/RelayCacheTracker/RelayCacheTracker.swift
new file mode 100644
index 0000000000..ef4bb33733
--- /dev/null
+++ b/ios/MullvadVPN/RelayCacheTracker/RelayCacheTracker.swift
@@ -0,0 +1,280 @@
+//
+// RelayCacheTracker.swift
+// MullvadVPN
+//
+// Created by pronebird on 05/06/2019.
+// Copyright © 2019 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadLogging
+import MullvadREST
+import MullvadTypes
+import Operations
+import RelayCache
+import UIKit
+
+final class RelayCacheTracker {
+ /// Relay update interval (in seconds).
+ static let relayUpdateInterval: TimeInterval = 60 * 60
+
+ /// Tracker log.
+ private let logger = Logger(label: "RelayCacheTracker")
+
+ /// Relay cache.
+ private let cache = RelayCache(
+ securityGroupIdentifier: ApplicationConfiguration.securityGroupIdentifier
+ )!
+
+ private let application: UIApplication
+
+ /// Lock used for synchronization.
+ private let nslock = NSLock()
+
+ /// Internal operation queue.
+ private let operationQueue: OperationQueue = {
+ let operationQueue = AsyncOperationQueue()
+ operationQueue.maxConcurrentOperationCount = 1
+ return operationQueue
+ }()
+
+ /// A timer source used for periodic updates.
+ private var timerSource: DispatchSourceTimer?
+
+ /// A flag that indicates whether periodic updates are running.
+ private var isPeriodicUpdatesEnabled = false
+
+ /// API proxy.
+ private let apiProxy: REST.APIProxy
+
+ /// Observers.
+ private let observerList = ObserverList<RelayCacheTrackerObserver>()
+
+ /// Memory cache.
+ private var cachedRelays: CachedRelays?
+
+ init(application: UIApplication, apiProxy: REST.APIProxy) {
+ self.application = application
+ self.apiProxy = apiProxy
+
+ do {
+ cachedRelays = try cache.read()
+ } catch {
+ logger.error(
+ error: error,
+ message: "Failed to read the relay cache during initialization."
+ )
+
+ _ = updateRelays(completionHandler: nil)
+ }
+ }
+
+ func startPeriodicUpdates() {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ guard !isPeriodicUpdatesEnabled else { return }
+
+ logger.debug("Start periodic relay updates.")
+
+ isPeriodicUpdatesEnabled = true
+
+ let nextUpdate = _getNextUpdateDate()
+
+ scheduleRepeatingTimer(startTime: .now() + nextUpdate.timeIntervalSinceNow)
+ }
+
+ func stopPeriodicUpdates() {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ guard isPeriodicUpdatesEnabled else { return }
+
+ logger.debug("Stop periodic relay updates.")
+
+ isPeriodicUpdatesEnabled = false
+
+ timerSource?.cancel()
+ timerSource = nil
+ }
+
+ func updateRelays(
+ completionHandler: (
+ (OperationCompletion<RelaysFetchResult, Error>) -> Void
+ )? = nil
+ ) -> Cancellable {
+ let operation = ResultBlockOperation<RelaysFetchResult, Error>(
+ dispatchQueue: nil
+ ) { operation in
+ let cachedRelays = try? self.getCachedRelays()
+
+ if self.getNextUpdateDate() > Date() {
+ operation.finish(completion: .success(.throttled))
+ return
+ }
+
+ let task = self.apiProxy.getRelays(
+ etag: cachedRelays?.etag,
+ retryStrategy: .noRetry
+ ) { completion in
+ operation.finish(
+ completion: self.handleResponse(completion: completion)
+ )
+ }
+
+ operation.addCancellationBlock {
+ task.cancel()
+ }
+ }
+
+ operation.addObserver(
+ BackgroundObserver(
+ application: application,
+ name: "Update relays",
+ cancelUponExpiration: true
+ )
+ )
+
+ operation.completionQueue = .main
+ operation.completionHandler = completionHandler
+
+ operationQueue.addOperation(operation)
+
+ return operation
+ }
+
+ func getCachedRelays() throws -> CachedRelays {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ if let cachedRelays = cachedRelays {
+ return cachedRelays
+ } else {
+ throw NoCachedRelaysError()
+ }
+ }
+
+ func getNextUpdateDate() -> Date {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ return _getNextUpdateDate()
+ }
+
+ // MARK: - Observation
+
+ func addObserver(_ observer: RelayCacheTrackerObserver) {
+ observerList.append(observer)
+ }
+
+ func removeObserver(_ observer: RelayCacheTrackerObserver) {
+ observerList.remove(observer)
+ }
+
+ // MARK: - Private
+
+ private func _getNextUpdateDate() -> Date {
+ let now = Date()
+
+ guard let cachedRelays = cachedRelays else {
+ return now
+ }
+
+ let nextUpdate = cachedRelays.updatedAt.addingTimeInterval(Self.relayUpdateInterval)
+
+ return max(nextUpdate, Date())
+ }
+
+ private func handleResponse(
+ completion: OperationCompletion<REST.ServerRelaysCacheResponse, REST.Error>
+ ) -> OperationCompletion<RelaysFetchResult, Error> {
+ let mappedCompletion = completion.tryMap { response -> RelaysFetchResult in
+ switch response {
+ case let .newContent(etag, relays):
+ try self.storeResponse(etag: etag, relays: relays)
+
+ return .newContent
+
+ case .notModified:
+ return .sameContent
+ }
+ }
+
+ if let error = mappedCompletion.error {
+ logger.error(
+ error: error,
+ message: "Failed to update relays."
+ )
+ }
+
+ return mappedCompletion
+ }
+
+ private func storeResponse(etag: String?, relays: REST.ServerRelaysResponse) throws {
+ let numRelays = relays.wireguard.relays.count
+
+ logger.info("Downloaded \(numRelays) relays.")
+
+ let newCachedRelays = CachedRelays(
+ etag: etag,
+ relays: relays,
+ updatedAt: Date()
+ )
+
+ nslock.lock()
+ cachedRelays = newCachedRelays
+ nslock.unlock()
+
+ try cache.write(record: newCachedRelays)
+
+ DispatchQueue.main.async {
+ self.observerList.forEach { observer in
+ observer.relayCacheTracker(self, didUpdateCachedRelays: newCachedRelays)
+ }
+ }
+ }
+
+ private func scheduleRepeatingTimer(startTime: DispatchWallTime) {
+ let timerSource = DispatchSource.makeTimerSource()
+ timerSource.setEventHandler { [weak self] in
+ _ = self?.updateRelays()
+ }
+
+ timerSource.schedule(
+ wallDeadline: startTime,
+ repeating: .seconds(Int(Self.relayUpdateInterval))
+ )
+ timerSource.activate()
+
+ self.timerSource = timerSource
+ }
+}
+
+/// Type describing the result of an attempt to fetch the new relay list from server.
+enum RelaysFetchResult: CustomStringConvertible {
+ /// Request to update relays was throttled.
+ case throttled
+
+ /// Refreshed relays but the same content was found on remote.
+ case sameContent
+
+ /// Refreshed relays with new content.
+ case newContent
+
+ var description: String {
+ switch self {
+ case .throttled:
+ return "throttled"
+ case .sameContent:
+ return "same content"
+ case .newContent:
+ return "new content"
+ }
+ }
+}
+
+struct NoCachedRelaysError: LocalizedError {
+ var errorDescription: String? {
+ return "Relay cache is empty."
+ }
+}
diff --git a/ios/MullvadVPN/RelayCacheTracker/RelayCacheTrackerObserver.swift b/ios/MullvadVPN/RelayCacheTracker/RelayCacheTrackerObserver.swift
new file mode 100644
index 0000000000..fcb38b55d6
--- /dev/null
+++ b/ios/MullvadVPN/RelayCacheTracker/RelayCacheTrackerObserver.swift
@@ -0,0 +1,17 @@
+//
+// RelayCacheTrackerObserver.swift
+// RelayCacheTrackerObserver
+//
+// Created by pronebird on 09/09/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import RelayCache
+
+protocol RelayCacheTrackerObserver: AnyObject {
+ func relayCacheTracker(
+ _ tracker: RelayCacheTracker,
+ didUpdateCachedRelays cachedRelays: CachedRelays
+ )
+}