diff options
| -rw-r--r-- | ios/CHANGELOG.md | 2 | ||||
| -rw-r--r-- | ios/MullvadTypes/PacketTunnelStatus.swift | 28 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 2 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager/TunnelManager.swift | 32 | ||||
| -rw-r--r-- | ios/PacketTunnel/PacketTunnelProvider.swift | 200 |
5 files changed, 261 insertions, 3 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 3a60a96ab9..0cb7b673df 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -35,6 +35,8 @@ Line wrap the file at 100 chars. Th - Add menu item to control shortcuts. - Add continuous monitoring of tunnel connection. Verify ping replies to detect whether traffic is really flowing. +- Check if device is revoked or account has expired when the tunnel fails to connect on each second + failed attempt. ### Changed - When logged into an account with no time left, a new view is shown instead of account settings, diff --git a/ios/MullvadTypes/PacketTunnelStatus.swift b/ios/MullvadTypes/PacketTunnelStatus.swift index a5844fddda..1e4cbf18ba 100644 --- a/ios/MullvadTypes/PacketTunnelStatus.swift +++ b/ios/MullvadTypes/PacketTunnelStatus.swift @@ -8,6 +8,30 @@ import Foundation +public struct DeviceCheck: Codable, Equatable { + /// Unique identifier for the device check. + /// Should only change when other fields in the struct are being changed. + public var identifier: UUID + + /// Flag indicating whether device is revoked. + /// Set to `nil` when the device status is unknown yet. + public var isDeviceRevoked: Bool? + + /// Last known account expiry. + /// Set to `nil` when account expiry is unknown yet. + public var accountExpiry: Date? + + public init( + identifier: UUID = UUID(), + isDeviceRevoked: Bool? = nil, + accountExpiry: Date? = nil + ) { + self.identifier = identifier + self.isDeviceRevoked = isDeviceRevoked + self.accountExpiry = accountExpiry + } +} + /// Struct describing packet tunnel process status. public struct PacketTunnelStatus: Codable, Equatable { /// Last tunnel error. @@ -16,16 +40,20 @@ public struct PacketTunnelStatus: Codable, Equatable { /// Flag indicating whether network is reachable. public var isNetworkReachable: Bool + /// Last performed device check. + public var deviceCheck: DeviceCheck? /// Current relay. public var tunnelRelay: PacketTunnelRelay? public init( lastError: String? = nil, isNetworkReachable: Bool = true, + deviceCheck: DeviceCheck? = nil, tunnelRelay: PacketTunnelRelay? = nil ) { self.lastError = lastError self.isNetworkReachable = isNetworkReachable + self.deviceCheck = deviceCheck self.tunnelRelay = tunnelRelay } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index a1cea3ff0a..c73d0c820c 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 061A2B67291121410084590B /* libOperations.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58E5126528DDF04200B0BCDE /* libOperations.a */; }; 062B45A328FD4CA700746E77 /* le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 06799AB428F98CE700ACD94E /* le_root_cert.cer */; }; 062B45AE28FD503000746E77 /* WireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = 062B45AD28FD503000746E77 /* WireGuardKit */; }; 062B45B428FD508C00746E77 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 062B45B328FD508C00746E77 /* Logging */; }; @@ -910,6 +911,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 061A2B67291121410084590B /* libOperations.a in Frameworks */, 5898D2AB2901845400EB5EBA /* libRelaySelector.a in Frameworks */, 5898D2AC2901845400EB5EBA /* libTunnelProviderMessaging.a in Frameworks */, 062B45B428FD508C00746E77 /* Logging in Frameworks */, diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 5061230e17..8d25f31ff1 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -82,6 +82,9 @@ final class TunnelManager { private var _tunnel: Tunnel? private var _tunnelStatus = TunnelStatus() + /// Last processed device check identifier. + private var lastDeviceCheckIdentifier: UUID? + // MARK: - Initialization private init(accountsProxy: REST.AccountsProxy, devicesProxy: REST.DevicesProxy) { @@ -665,6 +668,27 @@ final class TunnelManager { _tunnelStatus = newTunnelStatus + if let deviceCheck = newTunnelStatus.packetTunnelStatus.deviceCheck, + deviceCheck.identifier != lastDeviceCheckIdentifier + { + if deviceCheck.isDeviceRevoked ?? false { + didDetectDeviceRevoked() + + } else if let accountExpiry = deviceCheck.accountExpiry { + scheduleDeviceStateUpdate( + taskName: "Update account expiry", + reconnectTunnel: false + ) { deviceState in + if case .loggedIn(var accountData, let deviceData) = deviceState { + accountData.expiry = accountExpiry + deviceState = .loggedIn(accountData, deviceData) + } + } + } + + lastDeviceCheckIdentifier = deviceCheck.identifier + } + switch newTunnelStatus.state { case .connecting, .reconnecting: // Start polling tunnel status to keep the relay information up to date @@ -895,8 +919,9 @@ final class TunnelManager { private func scheduleDeviceStateUpdate( taskName: String, + reconnectTunnel: Bool = true, modificationBlock: @escaping (inout DeviceState) -> Void, - completionHandler: (() -> Void)? + completionHandler: (() -> Void)? = nil ) { let operation = AsyncBlockOperation(dispatchQueue: internalQueue) { var deviceState = self.deviceState @@ -904,7 +929,10 @@ final class TunnelManager { modificationBlock(&deviceState) self.setDeviceState(deviceState, persist: true) - self.reconnectTunnel(selectNewRelay: false, completionHandler: nil) + + if reconnectTunnel { + self.reconnectTunnel(selectNewRelay: false, completionHandler: nil) + } } operation.completionBlock = { diff --git a/ios/PacketTunnel/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider.swift index 1ec04c0afe..b222e81720 100644 --- a/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider.swift @@ -12,6 +12,7 @@ import MullvadREST import MullvadTypes import Network import NetworkExtension +import Operations import RelayCache import RelaySelector import TunnelProviderMessaging @@ -37,6 +38,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { /// Flag indicating whether network is reachable. private var isNetworkReachable = true + /// Struct holding result of the last device check. + private var deviceCheck: DeviceCheck? + + /// Number of consecutive connection failure attempts. + private var numberOfFailedAttempts: UInt = 0 + /// Last runtime error. private var lastError: Error? @@ -65,11 +72,23 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { /// Tunnel monitor. private var tunnelMonitor: TunnelMonitor! + /// Account data request proxy + private lazy var accountProxy = REST.ProxyFactory.shared.createAccountsProxy() + + /// Device data request proxy + private lazy var deviceProxy = REST.ProxyFactory.shared.createDevicesProxy() + + private lazy var checkDeviceStateTask: Cancellable? = nil + + /// Internal operation queue. + private let operationQueue = AsyncOperationQueue() + /// Returns `PacketTunnelStatus` used for sharing with main bundle process. private var packetTunnelStatus: PacketTunnelStatus { return PacketTunnelStatus( lastError: lastError?.localizedDescription, isNetworkReachable: isNetworkReachable, + deviceCheck: deviceCheck, tunnelRelay: selectorResult?.packetTunnelRelay ) } @@ -91,6 +110,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { super.init() + REST.TransportRegistry.shared.setTransport( + REST.URLSessionTransport(urlSession: urlSession) + ) + adapter = WireGuardAdapter( with: self, shouldHandleReasserting: false, @@ -197,7 +220,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { dispatchQueue.async { self.tunnelMonitor.stop() - + self.checkDeviceStateTask?.cancel() + self.checkDeviceStateTask = nil self.startTunnelCompletionHandler = nil self.reassertTunnelCompletionHandler = nil } @@ -320,6 +344,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { reassertTunnelCompletionHandler?() reassertTunnelCompletionHandler = nil + numberOfFailedAttempts = 0 + + checkDeviceStateTask?.cancel() + checkDeviceStateTask = nil + + deviceCheck = nil + setReconnecting(false) } @@ -329,6 +360,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { ) { dispatchPrecondition(condition: .onQueue(dispatchQueue)) + let (value, isOverflow) = numberOfFailedAttempts.addingReportingOverflow(1) + numberOfFailedAttempts = isOverflow ? 0 : value + + if numberOfFailedAttempts.isMultiple(of: 2) { + startDeviceCheck() + } + providerLogger.debug("Recover connection. Picking next relay...") reconnectTunnel(to: .automatic, completionHandler: completionHandler) @@ -451,6 +489,146 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { constraints: relayConstraints ) } + + // MARK: - Device check + + /// Fetch account and device data to verify account expiry and device status. + /// Saves the result into deviceCheck + private func startDeviceCheck() { + let deviceState: DeviceState + do { + deviceState = try SettingsManager.readDeviceState() + } catch { + providerLogger.error( + error: error, + message: "Failed to read device states" + ) + return + } + + guard case let .loggedIn(storedAccountData, storedDeviceData) = deviceState else { + return + } + + providerLogger.debug("Start device check") + + let accountOperation = createGetAccountDataOperation( + accountNumber: storedAccountData.number + ) + let deviceOperation = createGetDeviceDataOperation( + accountNumber: storedAccountData.number, + identifier: storedDeviceData.identifier + ) + + let completionOperation = AsyncBlockOperation(dispatchQueue: dispatchQueue) { [weak self] in + guard let self = self else { return } + + var newAccountExpiry: Date? + var newDeviceRevoked: Bool? + + switch accountOperation.completion { + case let .failure(error): + self.providerLogger.error( + error: error, + message: "Failed to fetch account data." + ) + + case let .success(accountData): + newAccountExpiry = accountData.expiry + + case .none, .cancelled: break + } + + switch deviceOperation.completion { + case let .failure(error): + if error.compareErrorCode(.deviceNotFound) { + newDeviceRevoked = true + } else { + self.providerLogger.error( + error: error, + message: "Failed to fetch device data." + ) + } + + case .none, .cancelled, .success: break + } + + if var deviceCheck = self.deviceCheck { + deviceCheck.update( + accountExpiry: newAccountExpiry, + isDeviceRevoked: newDeviceRevoked + ) + + self.deviceCheck = deviceCheck + } else { + self.deviceCheck = DeviceCheck( + identifier: UUID(), + isDeviceRevoked: newDeviceRevoked, + accountExpiry: newAccountExpiry + ) + } + + if newDeviceRevoked ?? false { + self.tunnelMonitor.stop() + } + } + + completionOperation.addDependencies([accountOperation, deviceOperation]) + + let groupOperation = GroupOperation(operations: [ + accountOperation, + deviceOperation, + completionOperation, + ]) + checkDeviceStateTask = groupOperation + + operationQueue.addOperation(groupOperation) + } + + private func createGetAccountDataOperation(accountNumber: String) + -> ResultOperation<REST.AccountData, REST.Error> + { + let operation = ResultBlockOperation<REST.AccountData, REST.Error>( + dispatchQueue: dispatchQueue + ) + + operation.setExecutionBlock { operation in + let task = self.accountProxy.getAccountData( + accountNumber: accountNumber, + retryStrategy: .noRetry + ) { completion in + operation.finish(completion: completion) + } + + operation.addCancellationBlock { + task.cancel() + } + } + + return operation + } + + private func createGetDeviceDataOperation(accountNumber: String, identifier: String) + -> ResultOperation<REST.Device, REST.Error> + { + let operation = ResultBlockOperation<REST.Device, REST.Error>(dispatchQueue: dispatchQueue) + + operation.setExecutionBlock { operation in + let task = self.deviceProxy.getDevice( + accountNumber: accountNumber, + identifier: identifier, + retryStrategy: .noRetry + ) { completion in + operation.finish(completion: completion) + } + + operation.addCancellationBlock { + task.cancel() + } + } + + return operation + } } /// Enum describing the next relay to connect to. @@ -461,3 +639,23 @@ private enum NextRelay { /// Determine next relay using relay selector. case automatic } + +extension DeviceCheck { + mutating func update(accountExpiry: Date?, isDeviceRevoked: Bool?) { + var shouldChangeIdentifier = false + + if let accountExpiry = accountExpiry, self.accountExpiry != accountExpiry { + shouldChangeIdentifier = true + self.accountExpiry = accountExpiry + } + + if let isDeviceRevoked = isDeviceRevoked, self.isDeviceRevoked != isDeviceRevoked { + shouldChangeIdentifier = true + self.isDeviceRevoked = isDeviceRevoked + } + + if shouldChangeIdentifier { + identifier = UUID() + } + } +} |
