diff --git a/ios/MullvadREST/RESTDefaults.swift b/ios/MullvadREST/RESTDefaults.swift index e9e8c50e1343..3ed5ad10995d 100644 --- a/ios/MullvadREST/RESTDefaults.swift +++ b/ios/MullvadREST/RESTDefaults.swift @@ -13,6 +13,12 @@ extension REST { /// Default API hostname. public static let defaultAPIHostname = "api.mullvad.net" + /// Exit ipV4 API hostname. + public static let ipV4APIHostname = "ipv4.am.i.mullvad.net" + + /// Exit ipV6 API hostname. + public static let ipV6APIHostname = "ipv6.am.i.mullvad.net" + /// Default API endpoint. public static let defaultAPIEndpoint = AnyIPEndpoint(string: "45.83.223.196:443")! diff --git a/ios/MullvadREST/RESTURLSession.swift b/ios/MullvadREST/RESTURLSession.swift index 99bafcee3cd2..b7584271ba4c 100644 --- a/ios/MullvadREST/RESTURLSession.swift +++ b/ios/MullvadREST/RESTURLSession.swift @@ -16,7 +16,7 @@ extension REST { let secCertificate = SecCertificateCreateWithData(nil, data as CFData)! let sessionDelegate = SSLPinningURLSessionDelegate( - sslHostname: defaultAPIHostname, + sslHostnames: [defaultAPIHostname, ipV4APIHostname, ipV6APIHostname], trustedRootCertificates: [secCertificate] ) diff --git a/ios/MullvadREST/SSLPinningURLSessionDelegate.swift b/ios/MullvadREST/SSLPinningURLSessionDelegate.swift index 6ab457fd4902..7918b577a738 100644 --- a/ios/MullvadREST/SSLPinningURLSessionDelegate.swift +++ b/ios/MullvadREST/SSLPinningURLSessionDelegate.swift @@ -11,13 +11,13 @@ import MullvadLogging import Security final class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate { - private let sslHostname: String + private let sslHostnames: [String] private let trustedRootCertificates: [SecCertificate] private let logger = Logger(label: "SSLPinningURLSessionDelegate") - init(sslHostname: String, trustedRootCertificates: [SecCertificate]) { - self.sslHostname = sslHostname + init(sslHostnames: [String], trustedRootCertificates: [SecCertificate]) { + self.sslHostnames = sslHostnames self.trustedRootCertificates = trustedRootCertificates } @@ -30,7 +30,7 @@ final class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate { ) { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let serverTrust = challenge.protectionSpace.serverTrust, - verifyServerTrust(serverTrust) { + sslHostnames.first(where: { verifyServerTrust($0, serverTrust: serverTrust) }) != nil { completionHandler(.useCredential, URLCredential(trust: serverTrust)) } else { completionHandler(.rejectProtectionSpace, nil) @@ -39,7 +39,7 @@ final class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate { // MARK: - Private - private func verifyServerTrust(_ serverTrust: SecTrust) -> Bool { + private func verifyServerTrust(_ sslHostname: String, serverTrust: SecTrust) -> Bool { var secResult: OSStatus // Set SSL policy diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 300905989385..97b21584733d 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -650,6 +650,15 @@ F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A29792A9F8A9B00EA3B6F /* VoucherTextField.swift */; }; F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A297A2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift */; }; F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A297F2A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift */; }; + F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */; }; + F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */; }; + F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */; }; + F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */; }; + F09D04BB2AE95396003D4F89 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */; }; + F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; }; + F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */; }; + F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; }; + F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; }; F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */; }; F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */; }; @@ -1636,6 +1645,13 @@ F09A29792A9F8A9B00EA3B6F /* VoucherTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoucherTextField.swift; sourceTree = ""; }; F09A297A2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherContentView.swift; sourceTree = ""; }; F09A297F2A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherInteractor.swift; sourceTree = ""; }; + F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxy.swift; sourceTree = ""; }; + F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OutgoingConnectionProxy+Stub.swift"; sourceTree = ""; }; + F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxyTests.swift; sourceTree = ""; }; + F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; + F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionService.swift; sourceTree = ""; }; + F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionServiceTests.swift; sourceTree = ""; }; + F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewConfiguration.swift; sourceTree = ""; }; F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountOperation.swift; sourceTree = ""; }; @@ -2128,14 +2144,15 @@ 583FE01E29C197D5006E85F9 /* Tunnel */ = { isa = PBXGroup; children = ( - 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */, - 58C3F4F82964B08300D72515 /* MapViewController.swift */, 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */, - 58CCA00F224249A1004F3011 /* TunnelViewController.swift */, 5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */, - 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */, 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */, + 58C3F4F82964B08300D72515 /* MapViewController.swift */, + F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, + 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */, + 58CCA00F224249A1004F3011 /* TunnelViewController.swift */, + 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */, ); path = Tunnel; sourceTree = ""; @@ -2493,6 +2510,10 @@ F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, 58C3FA652A38549D006A450A /* MockFileCache.swift */, + F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */, + F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */, + F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */, + F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */, A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */, A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */, 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, @@ -2503,6 +2524,7 @@ A9E0317B2ACBFC7E0095D843 /* TunnelStore+Stubs.swift */, A9E031792ACB0AE70095D843 /* UIApplication+Stubs.swift */, 58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */, + F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */, ); path = MullvadVPNTests; sourceTree = ""; @@ -2737,6 +2759,7 @@ 58C774C929AB543C003A1A56 /* Containers */, 58CAF9F22983D32200BE19F7 /* Coordinators */, 583FE02329C1AC9F006E85F9 /* Extensions */, + F09D04B82AE94F27003D4F89 /* GeneralAPIs */, 58B26E1F2943516500D5980C /* Notifications */, 586A950B2901250A007BAF2B /* Operations */, 5864859729A0D012006C5743 /* Presentation controllers */, @@ -3018,6 +3041,14 @@ path = RedeemVoucher; sourceTree = ""; }; + F09D04B82AE94F27003D4F89 /* GeneralAPIs */ = { + isa = PBXGroup; + children = ( + F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */, + ); + path = GeneralAPIs; + sourceTree = ""; + }; F0E361892A4ADCF500AEEF2B /* Welcome */ = { isa = PBXGroup; children = ( @@ -4100,10 +4131,13 @@ A9A5F9EE2ACB05160083449F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */, A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */, A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */, + F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */, A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */, A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, + F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */, + F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */, A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */, @@ -4144,12 +4178,14 @@ A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, + F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */, A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */, A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */, A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */, A9A5FA1A2ACB05160083449F /* StopTunnelOperation.swift in Sources */, A9A5FA1B2ACB05160083449F /* Tunnel.swift in Sources */, A9A5FA1C2ACB05160083449F /* Tunnel+Messaging.swift in Sources */, + F09D04BB2AE95396003D4F89 /* MockURLProtocol.swift in Sources */, A9A5FA1D2ACB05160083449F /* TunnelBlockObserver.swift in Sources */, A9A5FA1E2ACB05160083449F /* TunnelConfiguration.swift in Sources */, A9A5FA1F2ACB05160083449F /* TunnelInteractor.swift in Sources */, @@ -4159,6 +4195,7 @@ A9A5FA222ACB05160083449F /* TunnelObserver.swift in Sources */, A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */, A9E0317F2ACC331C0095D843 /* TunnelStatusBlockObserver.swift in Sources */, + F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */, A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */, A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */, A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */, @@ -4174,6 +4211,7 @@ A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */, A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */, A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */, + F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */, A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */, A9A5FA322ACB05160083449F /* RelayCacheTests.swift in Sources */, A9A5FA332ACB05160083449F /* RelaySelectorTests.swift in Sources */, @@ -4485,6 +4523,7 @@ F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */, 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */, 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */, + F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, @@ -4502,6 +4541,7 @@ 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, + F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */, 5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */, 5871167F2910035700D41AAC /* PreferencesInteractor.swift in Sources */, 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index af0a8f8e47a9..eb2e8be7b79c 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -77,6 +77,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo private let accountsProxy: RESTAccountHandling private var tunnelObserver: TunnelObserver? private var appPreferences: AppPreferencesDataSource + private var outgoingConnectionService: OutgoingConnectionServiceHandling private var outOfTimeTimer: Timer? @@ -91,6 +92,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo apiProxy: APIQuerying, devicesProxy: DeviceHandling, accountsProxy: RESTAccountHandling, + outgoingConnectionService: OutgoingConnectionServiceHandling, appPreferences: AppPreferencesDataSource ) { self.tunnelManager = tunnelManager @@ -100,6 +102,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo self.devicesProxy = devicesProxy self.accountsProxy = accountsProxy self.appPreferences = appPreferences + self.outgoingConnectionService = outgoingConnectionService super.init() @@ -676,7 +679,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo } private func makeTunnelCoordinator() -> TunnelCoordinator { - let tunnelCoordinator = TunnelCoordinator(tunnelManager: tunnelManager) + let tunnelCoordinator = TunnelCoordinator( + tunnelManager: tunnelManager, + outgoingConnectionService: outgoingConnectionService + ) tunnelCoordinator.showSelectLocationPicker = { [weak self] in self?.router.present(.selectLocation, animated: true) diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 255728ccef1b..5fa71da17c72 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -25,10 +25,16 @@ class TunnelCoordinator: Coordinator, Presenting { var showSelectLocationPicker: (() -> Void)? - init(tunnelManager: TunnelManager) { + init( + tunnelManager: TunnelManager, + outgoingConnectionService: OutgoingConnectionServiceHandling + ) { self.tunnelManager = tunnelManager - let interactor = TunnelViewControllerInteractor(tunnelManager: tunnelManager) + let interactor = TunnelViewControllerInteractor( + tunnelManager: tunnelManager, + outgoingConnectionService: outgoingConnectionService + ) controller = TunnelViewController(interactor: interactor) super.init() diff --git a/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift new file mode 100644 index 000000000000..9c67d11f2a35 --- /dev/null +++ b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift @@ -0,0 +1,112 @@ +// +// OutgoingConnectionProxy.swift +// MullvadREST +// +// Created by Mojgan on 2023-10-24. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes +import Network + +protocol OutgoingConnectionHandling { + func getIPV6(retryStrategy: REST.RetryStrategy) async throws -> OutgoingConnectionProxy.IPV6ConnectionData + func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> OutgoingConnectionProxy.IPV4ConnectionData +} + +final class OutgoingConnectionProxy: OutgoingConnectionHandling { + let urlSession: URLSession + + init(urlSession: URLSession) { + self.urlSession = urlSession + } + + func getIPV6(retryStrategy: REST.RetryStrategy) async throws -> IPV6ConnectionData { + try await perform(retryStrategy: retryStrategy, host: REST.ipV6APIHostname) + } + + func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> IPV4ConnectionData { + try await perform(retryStrategy: retryStrategy, host: REST.ipV4APIHostname) + } + + private func perform(retryStrategy: REST.RetryStrategy, host: String) async throws -> T { + let delayIterator = retryStrategy.makeDelayIterator() + for _ in 0 ..< retryStrategy.maxRetryCount { + do { + return try await perform(host: host) + } catch { + // ignore if request is cancelled + if case URLError.cancelled = error { + throw error + } else { + // retry with the delay + guard let delay = delayIterator.next() else { throw error } + let mills = UInt64(max(0, delay.milliseconds)) + let nanos = mills.saturatingMultiplication(1_000_000) + try await Task.sleep(nanoseconds: nanos) + } + } + } + return try await perform(host: host) + } + + private func perform(host: String) async throws -> T { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.host = host + urlComponents.path = "/json" + + guard let url = urlComponents.url else { + throw REST.Error.network(URLError(.badURL)) + } + do { + let request = URLRequest( + url: url, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval + ) + let (data, response) = try await urlSession.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw REST.Error.network(URLError(.badServerResponse)) + } + let decoder = JSONDecoder() + guard (200 ..< 300).contains(httpResponse.statusCode) else { + throw REST.Error.unhandledResponse( + httpResponse.statusCode, + try? decoder.decode( + REST.ServerErrorResponse.self, + from: data + ) + ) + } + let connectionData = try decoder.decode(T.self, from: data) + return connectionData + + } catch { + throw error + } + } +} + +extension OutgoingConnectionProxy { + typealias IPV4ConnectionData = OutgoingConnectionData + typealias IPV6ConnectionData = OutgoingConnectionData + typealias IPAddressType = Codable & IPAddress + + // MARK: - OutgoingConnectionData + + struct OutgoingConnectionData: Codable, Equatable { + let ip: T + let exitIP: Bool + + enum CodingKeys: String, CodingKey { + case ip, exitIP = "mullvad_exit_ip" + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.ip.rawValue == rhs.ip.rawValue && lhs.exitIP == rhs.exitIP + } + } +} diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index 1547ab0f50f1..7e72834926ec 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -69,6 +69,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand apiProxy: appDelegate.apiProxy, devicesProxy: appDelegate.devicesProxy, accountsProxy: appDelegate.accountsProxy, + outgoingConnectionService: OutgoingConnectionService( + outgoingConnectionProxy: OutgoingConnectionProxy(urlSession: REST.makeURLSession()) + ), appPreferences: AppPreferences() ) diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 8c23dedde59e..74086e46f569 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -94,6 +94,11 @@ enum UIMetrics { static let chipViewLayoutMargins = UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8) static let chipViewLabelSpacing: CGFloat = 7 } + + enum ConnectionPanelView { + static let inRowHeight: CGFloat = 22 + static let outRowHeight: CGFloat = 44 + } } extension UIMetrics { diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift index 65cae0c148a5..31804f74b343 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift @@ -75,9 +75,6 @@ class ConnectionPanelView: UIView { inAddressRow.translatesAutoresizingMaskIntoConstraints = false outAddressRow.translatesAutoresizingMaskIntoConstraints = false - // Remove this line when we have out address - outAddressRow.isHidden = true - inAddressRow.title = NSLocalizedString( "IN_ADDRESS_LABEL", tableName: "ConnectionPanel", @@ -105,6 +102,9 @@ class ConnectionPanelView: UIView { stackView.trailingAnchor.constraint(equalTo: trailingAnchor), stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + inAddressRow.heightAnchor.constraint(equalToConstant: UIMetrics.ConnectionPanelView.inRowHeight), + outAddressRow.heightAnchor.constraint(equalToConstant: UIMetrics.ConnectionPanelView.outRowHeight), + // Align all text labels with the guide, so that they maintain equal width textLabelLayoutGuide.trailingAnchor .constraint(equalTo: inAddressRow.textLabelLayoutGuide.trailingAnchor), @@ -125,6 +125,7 @@ class ConnectionPanelView: UIView { private func didChangeDataSource() { inAddressRow.value = dataSource?.inAddress outAddressRow.value = dataSource?.outAddress + outAddressRow.alpha = dataSource?.outAddress == nil ? 0 : 1.0 } private func toggleConnectionInfoVisibility() { @@ -182,6 +183,8 @@ class ConnectionPanelAddressRow: UIView { detailTextLabel.font = .systemFont(ofSize: 17) detailTextLabel.textColor = .white detailTextLabel.translatesAutoresizingMaskIntoConstraints = false + detailTextLabel.numberOfLines = .zero + detailTextLabel.lineBreakMode = .byWordWrapping return detailTextLabel }() @@ -189,6 +192,7 @@ class ConnectionPanelAddressRow: UIView { let stackView = UIStackView(arrangedSubviews: [textLabel, detailTextLabel]) stackView.spacing = UIStackView.spacingUseSystem stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .top return stackView }() diff --git a/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift b/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift new file mode 100644 index 000000000000..404012b66139 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift @@ -0,0 +1,44 @@ +// +// OutgoingConnectionService.swift +// MullvadVPN +// +// Created by Mojgan on 2023-10-27. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging +import Network + +protocol OutgoingConnectionServiceHandling { + func getOutgoingConnectionInfo() async throws -> OutgoingConnectionInfo +} + +final class OutgoingConnectionService: OutgoingConnectionServiceHandling { + private let outgoingConnectionProxy: OutgoingConnectionHandling + + init(outgoingConnectionProxy: OutgoingConnectionHandling) { + self.outgoingConnectionProxy = outgoingConnectionProxy + } + + func getOutgoingConnectionInfo() async throws -> OutgoingConnectionInfo { + let ipv4ConnectionInfo = try await outgoingConnectionProxy.getIPV4(retryStrategy: .default) + let ipv6ConnectionInfo = try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry) + return OutgoingConnectionInfo(ipv4: ipv4ConnectionInfo, ipv6: ipv6ConnectionInfo) + } +} + +struct OutgoingConnectionInfo { + /// IPv4 exit connection. + let ipv4: OutgoingConnectionProxy.IPV4ConnectionData + + /// IPv6 exit connection. + let ipv6: OutgoingConnectionProxy.IPV6ConnectionData + + var outAddress: String? { + let v4 = ipv4.exitIP ? "\(ipv4.ip)" : nil + let v6 = ipv6.exitIP ? "\(ipv6.ip)" : nil + let outAddress = [v4, v6].compactMap { $0 }.joined(separator: "\n") + return outAddress.isEmpty ? nil : outAddress + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index 4abc59791cd4..132a9729111c 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift @@ -147,6 +147,15 @@ final class TunnelControlView: UIView { updateTunnelRelay() } + func update(from outgoingConnectionInfo: OutgoingConnectionInfo) { + if let tunnelRelay = tunnelState.relay { + connectionPanel.dataSource = ConnectionPanelData( + inAddress: "\(tunnelRelay.endpoint.ipv4Relay) UDP", + outAddress: outgoingConnectionInfo.outAddress + ) + } + } + func setAnimatingActivity(_ isAnimating: Bool) { if isAnimating { activityIndicator.startAnimating() @@ -223,6 +232,7 @@ final class TunnelControlView: UIView { ) connectionPanel.dataSource = ConnectionPanelData( + // TODO: - UDP shouldn't be hardcoded after tunnel obfuscation inAddress: "\(tunnelRelay.endpoint.ipv4Relay) UDP", outAddress: nil ) @@ -261,8 +271,7 @@ final class TunnelControlView: UIView { containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), containerView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), - locationContainerView.topAnchor - .constraint(greaterThanOrEqualTo: containerView.topAnchor), + locationContainerView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor), locationContainerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), locationContainerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index 10095a0f3a3d..3c6cc11ea603 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -63,6 +63,10 @@ class TunnelViewController: UIViewController, RootContainment { self?.setTunnelState(tunnelStatus.state, animated: true) } + interactor.didGetOutGoingAddress = { [weak self] outgoingConnectionInfo in + self?.contentView.update(from: outgoingConnectionInfo) + } + contentView.actionHandler = { [weak self] action in switch action { case .connect: @@ -143,7 +147,6 @@ class TunnelViewController: UIViewController, RootContainment { case let .connected(tunnelRelay): let center = tunnelRelay.location.geoCoordinate - mapViewController.setCenter(center, animated: animated) { self.contentView.setAnimatingActivity(false) self.mapViewController.addLocationMarker(coordinate: center) diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift index 47adb52703c5..47b75fd7d5ef 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift @@ -8,13 +8,17 @@ import Foundation import MullvadSettings +import MullvadTypes final class TunnelViewControllerInteractor { private let tunnelManager: TunnelManager + private let outgoingConnectionService: OutgoingConnectionServiceHandling private var tunnelObserver: TunnelObserver? + private var outgoingConnectionTask: Task? - var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)? var didUpdateTunnelStatus: ((TunnelStatus) -> Void)? + var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)? + var didGetOutGoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? var tunnelStatus: TunnelStatus { tunnelManager.tunnelStatus @@ -24,17 +28,37 @@ final class TunnelViewControllerInteractor { tunnelManager.deviceState } - init(tunnelManager: TunnelManager) { + deinit { + outgoingConnectionTask?.cancel() + } + + init( + tunnelManager: TunnelManager, + outgoingConnectionService: OutgoingConnectionServiceHandling + ) { self.tunnelManager = tunnelManager + self.outgoingConnectionService = outgoingConnectionService let tunnelObserver = TunnelBlockObserver( didUpdateTunnelStatus: { [weak self] _, tunnelStatus in - self?.didUpdateTunnelStatus?(tunnelStatus) + guard let self else { return } + outgoingConnectionTask?.cancel() + didUpdateTunnelStatus?(tunnelStatus) + if case .connected = tunnelStatus.state { + outgoingConnectionTask = Task(priority: .high) { [weak self] in + guard let outgoingConnectionInfo = try await self?.outgoingConnectionService + .getOutgoingConnectionInfo() else { + return + } + await self?.didGetOutGoingAddress?(outgoingConnectionInfo) + } + } }, didUpdateDeviceState: { [weak self] _, deviceState, previousDeviceState in self?.didUpdateDeviceState?(deviceState, previousDeviceState) } ) + tunnelManager.addObserver(tunnelObserver) self.tunnelObserver = tunnelObserver diff --git a/ios/MullvadVPNTests/MockURLProtocol.swift b/ios/MullvadVPNTests/MockURLProtocol.swift new file mode 100644 index 000000000000..061a132528f2 --- /dev/null +++ b/ios/MullvadVPNTests/MockURLProtocol.swift @@ -0,0 +1,55 @@ +// +// MockURLProtocol.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-10-25. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +class MockURLProtocol: URLProtocol { + static var error: Error? + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + if let error = MockURLProtocol.error { + client?.urlProtocol(self, didFailWithError: error) + return + } + + guard let handler = MockURLProtocol.requestHandler else { + assertionFailure("Received unexpected request with no handler set") + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { + // stop loading here + } +} + +extension URLSession { + static let mock = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + }() +} diff --git a/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift b/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift new file mode 100644 index 000000000000..b8bbc9bb90c5 --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift @@ -0,0 +1,42 @@ +// +// File.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-10-25. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST + +struct OutgoingConnectionProxyStub: OutgoingConnectionHandling { + var hasError = false + var ipV4: OutgoingConnectionProxy.IPV4ConnectionData + var ipV6: OutgoingConnectionProxy.IPV6ConnectionData + var error: Error + + func getIPV6(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> OutgoingConnectionProxy + .IPV6ConnectionData { + if hasError { + throw error + } else { + return ipV6 + } + } + + func getIPV4(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> OutgoingConnectionProxy + .IPV4ConnectionData { + if hasError { + throw error + } else { + return ipV4 + } + } +} + +extension OutgoingConnectionProxy.IPV4ConnectionData { + static let mock = OutgoingConnectionProxy.IPV4ConnectionData(ip: .loopback, exitIP: true) +} + +extension OutgoingConnectionProxy.IPV6ConnectionData { + static let mock = OutgoingConnectionProxy.IPV6ConnectionData(ip: .loopback, exitIP: true) +} diff --git a/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift new file mode 100644 index 000000000000..35d934756436 --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift @@ -0,0 +1,127 @@ +// +// OutgoingConnectionProxyTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-10-25. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// +import MullvadREST +import XCTest + +final class OutgoingConnectionProxyTests: XCTestCase { + private var outgoingConnectionProxy: OutgoingConnectionProxy! + private var mockIPV6ConnectionData: Data! + private var mockIPV4ConnectionData: Data! + + private let encoder = JSONEncoder() + + override func setUpWithError() throws { + outgoingConnectionProxy = OutgoingConnectionProxy(urlSession: .mock) + mockIPV4ConnectionData = try encoder.encode(OutgoingConnectionProxy.IPV4ConnectionData.mock) + mockIPV6ConnectionData = try encoder.encode(OutgoingConnectionProxy.IPV6ConnectionData.mock) + } + + override func tearDownWithError() throws { + outgoingConnectionProxy = nil + mockIPV4ConnectionData.removeAll() + mockIPV6ConnectionData.removeAll() + } + + func testNoInternetConnection() async throws { + let noIPv4Expectation = expectation(description: "Did not receive IPv4") + let error = URLError(URLError.notConnectedToInternet) + + MockURLProtocol.error = error + MockURLProtocol.requestHandler = nil + + await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry)) { error in + noIPv4Expectation.fulfill() + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + await fulfillment(of: [noIPv4Expectation], timeout: 1) + } + + func testSuccessGettingIPV4() async throws { + let iPv4Expectation = expectation(description: "Did receive IPv4") + + MockURLProtocol.error = nil + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://ipv4.am.i.mullvad.net/json")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, self.mockIPV4ConnectionData) + } + + let result = try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry) + + if result.ip == OutgoingConnectionProxy.IPV4ConnectionData.mock.ip { + iPv4Expectation.fulfill() + } + await fulfillment(of: [iPv4Expectation], timeout: 1) + } + + func testFailureGettingIPV4() async throws { + let noIPv4Expectation = expectation(description: "Did not receive IPv4") + + MockURLProtocol.error = nil + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://ipv4.am.i.mullvad.net/json")!, + statusCode: 503, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data()) + } + + await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry)) { _ in + noIPv4Expectation.fulfill() + } + await fulfillment(of: [noIPv4Expectation], timeout: 1) + } + + func testSuccessGettingIPV6() async throws { + let ipv6Expectation = expectation(description: "Did receive IPv6") + + MockURLProtocol.error = nil + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://ipv6.am.i.mullvad.net/json")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, self.mockIPV6ConnectionData) + } + + let result = try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry) + + if result.ip == OutgoingConnectionProxy.IPV6ConnectionData.mock.ip { + ipv6Expectation.fulfill() + } + await fulfillment(of: [ipv6Expectation], timeout: 1.0) + } + + func testFailureGettingIPV6() async throws { + let noIPv6Expectation = expectation(description: "Did not receive IPv6") + + MockURLProtocol.error = nil + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://ipv6.am.i.mullvad.net/json")!, + statusCode: 404, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data()) + } + + await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry)) { _ in + noIPv6Expectation.fulfill() + } + await fulfillment(of: [noIPv6Expectation], timeout: 1) + } +} diff --git a/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift new file mode 100644 index 000000000000..88adb63d3659 --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift @@ -0,0 +1,50 @@ +// +// OutgoingConnectionServiceTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-11-02. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +final class OutgoingConnectionServiceTests: XCTestCase { + func testSuccessGetOutgoingConnectionInfo() async throws { + let mockOutgoingConnectionProxy = OutgoingConnectionProxyStub( + ipV4: .mock, + ipV6: .mock, + error: NetworkErrorStub.somethingWentWrong + ) + let outgoingConnectionService = OutgoingConnectionService(outgoingConnectionProxy: mockOutgoingConnectionProxy) + let successExpectation = expectation(description: "should succeed") + let result = try await outgoingConnectionService.getOutgoingConnectionInfo() + if result.ipv4 == .mock, + result.ipv6 == .mock { + successExpectation.fulfill() + } + await fulfillment(of: [successExpectation], timeout: 1.0) + } + + func testFailureGetOutgoingConnectionInfo() async throws { + var mockOutgoingConnectionProxy = OutgoingConnectionProxyStub( + ipV4: .mock, + ipV6: .mock, + error: NetworkErrorStub.somethingWentWrong + ) + mockOutgoingConnectionProxy.hasError = true + let outgoingConnectionService = OutgoingConnectionService(outgoingConnectionProxy: mockOutgoingConnectionProxy) + + let failExpectation = expectation(description: "should fail") + do { + _ = try await outgoingConnectionService.getOutgoingConnectionInfo() + } catch { + failExpectation.fulfill() + } + await fulfillment(of: [failExpectation], timeout: 1.0) + } +} + +enum NetworkErrorStub: Error { + case somethingWentWrong +} diff --git a/ios/MullvadVPNTests/XCTest+Async.swift b/ios/MullvadVPNTests/XCTest+Async.swift new file mode 100644 index 000000000000..0db26f945843 --- /dev/null +++ b/ios/MullvadVPNTests/XCTest+Async.swift @@ -0,0 +1,27 @@ +// +// XCTest+Async.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-11-10. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +extension XCTest { + func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (_ error: Error) -> Void = { _ in } + ) async { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + errorHandler(error) + } + } +}