diff --git a/ios/MullvadMockData/MullvadREST/MockProxyFactory.swift b/ios/MullvadMockData/MullvadREST/MockProxyFactory.swift index f6a101808c12..62f31c5b9400 100644 --- a/ios/MullvadMockData/MullvadREST/MockProxyFactory.swift +++ b/ios/MullvadMockData/MullvadREST/MockProxyFactory.swift @@ -29,13 +29,13 @@ public struct MockProxyFactory: ProxyFactoryProtocol { public static func makeProxyFactory( transportProvider: any RESTTransportProvider, - addressCache: REST.AddressCache, - apiContext: MullvadApiContext + apiTransportProvider: any APITransportProviderProtocol, + addressCache: REST.AddressCache ) -> any ProxyFactoryProtocol { let basicConfiguration = REST.ProxyConfiguration( transportProvider: transportProvider, - addressCacheStore: addressCache, - apiContext: apiContext + apiTransportProvider: apiTransportProvider, + addressCacheStore: addressCache ) let authenticationProxy = REST.AuthenticationProxy( @@ -47,8 +47,7 @@ public struct MockProxyFactory: ProxyFactoryProtocol { let authConfiguration = REST.AuthProxyConfiguration( proxyConfiguration: basicConfiguration, - accessTokenManager: accessTokenManager, - apiContext: apiContext + accessTokenManager: accessTokenManager ) return MockProxyFactory(configuration: authConfiguration) diff --git a/ios/MullvadREST/APIRequest/APIError.swift b/ios/MullvadREST/APIRequest/APIError.swift new file mode 100644 index 000000000000..f62fde619ace --- /dev/null +++ b/ios/MullvadREST/APIRequest/APIError.swift @@ -0,0 +1,19 @@ +// +// APIError.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-02-24. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +public struct APIError: Error, Codable, Sendable { + public let statusCode: Int + public let errorDescription: String + public let serverResponseCode: String? + + public init(statusCode: Int, errorDescription: String, serverResponseCode: String?) { + self.statusCode = statusCode + self.errorDescription = errorDescription + self.serverResponseCode = serverResponseCode + } +} diff --git a/ios/MullvadREST/APIRequest/APIRequest.swift b/ios/MullvadREST/APIRequest/APIRequest.swift new file mode 100644 index 000000000000..4fff7bd32bca --- /dev/null +++ b/ios/MullvadREST/APIRequest/APIRequest.swift @@ -0,0 +1,38 @@ +// +// APIRequest.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-02-24. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +public enum APIRequest: Codable, Sendable { + case getAddressList(_ retryStrategy: REST.RetryStrategy) + + var retryStrategy: REST.RetryStrategy { + switch self { + case let .getAddressList(strategy): + return strategy + } + } +} + +public struct ProxyAPIRequest: Codable, Sendable { + public let id: UUID + public let request: APIRequest + + public init(id: UUID, request: APIRequest) { + self.id = id + self.request = request + } +} + +public struct ProxyAPIResponse: Codable, Sendable { + public let data: Data? + public let error: APIError? + + public init(data: Data?, error: APIError?) { + self.data = data + self.error = error + } +} diff --git a/ios/MullvadREST/APIRequest/APIRequestProxy.swift b/ios/MullvadREST/APIRequest/APIRequestProxy.swift new file mode 100644 index 000000000000..8e2ac4fad2eb --- /dev/null +++ b/ios/MullvadREST/APIRequest/APIRequestProxy.swift @@ -0,0 +1,89 @@ +// +// APIRequestProxy.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-02-13. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import MullvadRustRuntime +import MullvadTypes + +public protocol APIRequestProxyProtocol { + func sendRequest(_ proxyRequest: ProxyAPIRequest, completion: @escaping @Sendable (ProxyAPIResponse) -> Void) + func sendRequest(_ proxyRequest: ProxyAPIRequest) async -> ProxyAPIResponse + func cancelRequest(identifier: UUID) +} + +/// Network request proxy capable of passing serializable requests and responses over the given transport provider. +public final class APIRequestProxy: APIRequestProxyProtocol, @unchecked Sendable { + /// Serial queue used for synchronizing access to class members. + private let dispatchQueue: DispatchQueue + + private let transportProvider: APITransportProviderProtocol + + /// List of all proxied network requests bypassing VPN. + private var proxiedRequests: [UUID: Cancellable] = [:] + + public init( + dispatchQueue: DispatchQueue, + transportProvider: APITransportProviderProtocol + ) { + self.dispatchQueue = dispatchQueue + self.transportProvider = transportProvider + } + + public func sendRequest( + _ proxyRequest: ProxyAPIRequest, + completion: @escaping @Sendable (ProxyAPIResponse) -> Void + ) { + dispatchQueue.async { + guard let transport = self.transportProvider.makeTransport() else { + // Cancel old task, if there's one scheduled. + self.cancelRequest(identifier: proxyRequest.id) + + completion(ProxyAPIResponse(data: nil, error: nil)) + return + } + + let cancellable = transport.sendRequest(proxyRequest.request) { [weak self] response in + guard let self else { return } + + // Use `dispatchQueue` to guarantee thread safe access to `proxiedRequests` + dispatchQueue.async { + _ = self.removeRequest(identifier: proxyRequest.id) + completion(response) + } + } + + // Cancel old task, if there's one scheduled. + let oldTask = self.addRequest(identifier: proxyRequest.id, task: cancellable) + oldTask?.cancel() + } + } + + public func sendRequest(_ proxyRequest: ProxyAPIRequest) async -> ProxyAPIResponse { + return await withCheckedContinuation { continuation in + sendRequest(proxyRequest) { proxyResponse in + continuation.resume(returning: proxyResponse) + } + } + } + + public func cancelRequest(identifier: UUID) { + dispatchQueue.async { + let task = self.removeRequest(identifier: identifier) + task?.cancel() + } + } + + private func addRequest(identifier: UUID, task: Cancellable) -> Cancellable? { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + return proxiedRequests.updateValue(task, forKey: identifier) + } + + private func removeRequest(identifier: UUID) -> Cancellable? { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + return proxiedRequests.removeValue(forKey: identifier) + } +} diff --git a/ios/MullvadREST/ApiHandlers/MullvadApiNetworkOperation.swift b/ios/MullvadREST/ApiHandlers/MullvadApiNetworkOperation.swift new file mode 100644 index 000000000000..68d4ecb0c763 --- /dev/null +++ b/ios/MullvadREST/ApiHandlers/MullvadApiNetworkOperation.swift @@ -0,0 +1,110 @@ +// +// MullvadApiNetworkOperation.swift +// MullvadREST +// +// Created by Jon Petersson on 2025-01-29. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging +import MullvadRustRuntime +import MullvadTypes +import Operations + +private enum MullvadApiTransportError: Error { + case connectionFailed(description: String?) +} + +extension REST { + class MullvadApiNetworkOperation: ResultOperation, @unchecked Sendable { + private let logger: Logger + + private let request: APIRequest + private let transportProvider: APITransportProviderProtocol + private var responseDecoder: JSONDecoder + private let responseHandler: any RESTRustResponseHandler + private var networkTask: Cancellable? + + init( + name: String, + dispatchQueue: DispatchQueue, + request: APIRequest, + transportProvider: APITransportProviderProtocol, + responseDecoder: JSONDecoder, + responseHandler: some RESTRustResponseHandler, + completionHandler: CompletionHandler? = nil + ) { + self.request = request + self.transportProvider = transportProvider + self.responseDecoder = responseDecoder + self.responseHandler = responseHandler + + var logger = Logger(label: "REST.RustNetworkOperation") + + logger[metadataKey: "name"] = .string(name) + self.logger = logger + + super.init( + dispatchQueue: dispatchQueue, + completionQueue: .main, + completionHandler: completionHandler + ) + } + + override public func operationDidCancel() { + networkTask?.cancel() + networkTask = nil + } + + override public func main() { + startRequest() + } + + func startRequest() { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + guard !isCancelled else { + finish(result: .failure(OperationError.cancelled)) + return + } + + let transport = transportProvider.makeTransport() + networkTask = transport?.sendRequest(request) { [weak self] response in + guard let self else { return } + + if let apiError = response.error { + finish(result: .failure(restError(apiError: apiError))) + return + } + + let decodedResponse = responseHandler.handleResponse(response.data) + + switch decodedResponse { + case let .success(value): + finish(result: .success(value)) + case let .decoding(block): + do { + finish(result: .success(try block())) + } catch { + finish(result: .failure(REST.Error.unhandledResponse(0, nil))) + } + case let .unhandledResponse(error): + finish(result: .failure(REST.Error.unhandledResponse(0, error))) + } + } + } + + private func restError(apiError: APIError) -> Error { + guard let serverResponseCode = apiError.serverResponseCode else { + return .transport(MullvadApiTransportError.connectionFailed(description: apiError.errorDescription)) + } + + let response = REST.ServerErrorResponse( + code: REST.ServerResponseCode(rawValue: serverResponseCode), + detail: apiError.errorDescription + ) + return .unhandledResponse(apiError.statusCode, response) + } + } +} diff --git a/ios/MullvadREST/ApiHandlers/MullvadApiRequestFactory.swift b/ios/MullvadREST/ApiHandlers/MullvadApiRequestFactory.swift index d40039d558fc..d361beef1b86 100644 --- a/ios/MullvadREST/ApiHandlers/MullvadApiRequestFactory.swift +++ b/ios/MullvadREST/ApiHandlers/MullvadApiRequestFactory.swift @@ -9,14 +9,14 @@ import MullvadRustRuntime import MullvadTypes -enum MullvadApiRequest { - case getAddressList(retryStrategy: REST.RetryStrategy) -} +public struct MullvadApiRequestFactory: Sendable { + public let apiContext: MullvadApiContext -struct MullvadApiRequestFactory { - let apiContext: MullvadApiContext + public init(apiContext: MullvadApiContext) { + self.apiContext = apiContext + } - func makeRequest(_ request: MullvadApiRequest) -> REST.MullvadApiRequestHandler { + public func makeRequest(_ request: APIRequest) -> REST.MullvadApiRequestHandler { { completion in let pointerClass = MullvadApiCompletion { apiResponse in try? completion?(apiResponse) @@ -37,5 +37,5 @@ struct MullvadApiRequestFactory { } extension REST { - typealias MullvadApiRequestHandler = (((MullvadApiResponse) throws -> Void)?) -> MullvadApiCancellable + public typealias MullvadApiRequestHandler = (((MullvadApiResponse) throws -> Void)?) -> MullvadApiCancellable } diff --git a/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift b/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift index 1dfd01b545ef..908e0bddd46c 100644 --- a/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift +++ b/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift @@ -66,8 +66,6 @@ extension REST { retryStrategy: REST.RetryStrategy, completionHandler: @escaping @Sendable ProxyCompletionHandler<[AnyIPEndpoint]> ) -> Cancellable { - let requestHandler = mullvadApiRequestFactory.makeRequest(.getAddressList(retryStrategy: retryStrategy)) - let responseHandler = rustResponseHandler( decoding: [AnyIPEndpoint].self, with: responseDecoder @@ -76,7 +74,8 @@ extension REST { let networkOperation = MullvadApiNetworkOperation( name: "get-api-addrs", dispatchQueue: dispatchQueue, - requestHandler: requestHandler, + request: .getAddressList(retryStrategy), + transportProvider: configuration.apiTransportProvider, responseDecoder: responseDecoder, responseHandler: responseHandler, completionHandler: completionHandler diff --git a/ios/MullvadREST/ApiHandlers/RESTProxy.swift b/ios/MullvadREST/ApiHandlers/RESTProxy.swift index 75d1e31fd800..cabfd4bfdf04 100644 --- a/ios/MullvadREST/ApiHandlers/RESTProxy.swift +++ b/ios/MullvadREST/ApiHandlers/RESTProxy.swift @@ -27,8 +27,6 @@ extension REST { /// URL request factory. let requestFactory: REST.RequestFactory - let mullvadApiRequestFactory: MullvadApiRequestFactory - /// URL response decoder. let responseDecoder: JSONDecoder @@ -43,7 +41,6 @@ extension REST { self.configuration = configuration self.requestFactory = requestFactory - self.mullvadApiRequestFactory = MullvadApiRequestFactory(apiContext: configuration.apiContext) self.responseDecoder = responseDecoder } @@ -135,17 +132,17 @@ extension REST { public class ProxyConfiguration: @unchecked Sendable { public let transportProvider: RESTTransportProvider + public let apiTransportProvider: APITransportProviderProtocol public let addressCacheStore: AddressCache - public let apiContext: MullvadApiContext public init( transportProvider: RESTTransportProvider, - addressCacheStore: AddressCache, - apiContext: MullvadApiContext + apiTransportProvider: APITransportProviderProtocol, + addressCacheStore: AddressCache ) { self.transportProvider = transportProvider + self.apiTransportProvider = apiTransportProvider self.addressCacheStore = addressCacheStore - self.apiContext = apiContext } } @@ -154,15 +151,14 @@ extension REST { public init( proxyConfiguration: ProxyConfiguration, - accessTokenManager: RESTAccessTokenManagement, - apiContext: MullvadApiContext + accessTokenManager: RESTAccessTokenManagement ) { self.accessTokenManager = accessTokenManager super.init( transportProvider: proxyConfiguration.transportProvider, - addressCacheStore: proxyConfiguration.addressCacheStore, - apiContext: apiContext + apiTransportProvider: proxyConfiguration.apiTransportProvider, + addressCacheStore: proxyConfiguration.addressCacheStore ) } } diff --git a/ios/MullvadREST/ApiHandlers/RESTProxyFactory.swift b/ios/MullvadREST/ApiHandlers/RESTProxyFactory.swift index 46acaa94bfb6..7515b92c5c84 100644 --- a/ios/MullvadREST/ApiHandlers/RESTProxyFactory.swift +++ b/ios/MullvadREST/ApiHandlers/RESTProxyFactory.swift @@ -18,8 +18,8 @@ public protocol ProxyFactoryProtocol { static func makeProxyFactory( transportProvider: RESTTransportProvider, - addressCache: REST.AddressCache, - apiContext: MullvadApiContext + apiTransportProvider: APITransportProviderProtocol, + addressCache: REST.AddressCache ) -> ProxyFactoryProtocol } @@ -29,13 +29,13 @@ extension REST { public static func makeProxyFactory( transportProvider: any RESTTransportProvider, - addressCache: REST.AddressCache, - apiContext: MullvadApiContext + apiTransportProvider: any APITransportProviderProtocol, + addressCache: REST.AddressCache ) -> any ProxyFactoryProtocol { let basicConfiguration = REST.ProxyConfiguration( transportProvider: transportProvider, - addressCacheStore: addressCache, - apiContext: apiContext + apiTransportProvider: apiTransportProvider, + addressCacheStore: addressCache ) let authenticationProxy = REST.AuthenticationProxy( @@ -47,8 +47,7 @@ extension REST { let authConfiguration = REST.AuthProxyConfiguration( proxyConfiguration: basicConfiguration, - accessTokenManager: accessTokenManager, - apiContext: apiContext + accessTokenManager: accessTokenManager ) return ProxyFactory(configuration: authConfiguration) diff --git a/ios/MullvadREST/ApiHandlers/RESTResponseHandler.swift b/ios/MullvadREST/ApiHandlers/RESTResponseHandler.swift index 1b6d7f950b89..c6197e983ea9 100644 --- a/ios/MullvadREST/ApiHandlers/RESTResponseHandler.swift +++ b/ios/MullvadREST/ApiHandlers/RESTResponseHandler.swift @@ -19,7 +19,7 @@ protocol RESTResponseHandler { protocol RESTRustResponseHandler { associatedtype Success - func handleResponse(_ response: MullvadApiResponse) -> REST.ResponseHandlerResult + func handleResponse(_ body: Data?) -> REST.ResponseHandlerResult } extension REST { @@ -76,7 +76,7 @@ extension REST { } final class RustResponseHandler: RESTRustResponseHandler { - typealias HandlerBlock = (MullvadApiResponse) -> REST.ResponseHandlerResult + typealias HandlerBlock = (Data?) -> REST.ResponseHandlerResult private let handlerBlock: HandlerBlock @@ -84,8 +84,8 @@ extension REST { handlerBlock = block } - func handleResponse(_ response: MullvadApiResponse) -> REST.ResponseHandlerResult { - handlerBlock(response) + func handleResponse(_ body: Data?) -> REST.ResponseHandlerResult { + handlerBlock(body) } } @@ -96,28 +96,20 @@ extension REST { decoding type: T.Type, with decoder: JSONDecoder ) -> RustResponseHandler { - RustResponseHandler { response in - guard let body = response.body else { + RustResponseHandler { data in + guard let data else { return .unhandledResponse(nil) } - do { - let decoded = try decoder.decode(type, from: body) - return .decoding { decoded } - } catch { - return .unhandledResponse( - try? decoder.decode( - ServerErrorResponse.self, - from: body - ) - ) + return if let decoded = try? decoder.decode(type, from: data) { + .decoding { decoded } + } else { + .unhandledResponse(nil) } } } - /// Returns default response handler that parses JSON response into the - /// given `Decodable` type if possible, otherwise attempts to decode - /// the server error. + /// Response handler for reponses where the body is empty. static func rustEmptyResponseHandler() -> RustResponseHandler { RustResponseHandler { _ in .success(()) diff --git a/ios/MullvadREST/RetryStrategy/RetryStrategy.swift b/ios/MullvadREST/RetryStrategy/RetryStrategy.swift index 4f0d7016af25..a8b4cb4980d7 100644 --- a/ios/MullvadREST/RetryStrategy/RetryStrategy.swift +++ b/ios/MullvadREST/RetryStrategy/RetryStrategy.swift @@ -11,7 +11,7 @@ import MullvadRustRuntime import MullvadTypes extension REST { - public struct RetryStrategy: Sendable { + public struct RetryStrategy: Codable, Sendable { public var maxRetryCount: Int public var delay: RetryDelay public var applyJitter: Bool @@ -50,6 +50,8 @@ extension REST { AnyIterator(Jittered(inner)) case let .exponentialBackoff(_, _, maxDelay): AnyIterator(Transformer(inner: Jittered(inner)) { nextValue in + let maxDelay = maxDelay.duration + guard let nextValue else { return maxDelay } return nextValue >= maxDelay ? maxDelay : nextValue }) @@ -108,15 +110,15 @@ extension REST { ) } - public enum RetryDelay: Equatable, Sendable { + public enum RetryDelay: Codable, Equatable, Sendable { /// Never wait to retry. case never /// Constant delay. - case constant(Duration) + case constant(CodableDuration) /// Exponential backoff. - case exponentialBackoff(initial: Duration, multiplier: UInt64, maxDelay: Duration) + case exponentialBackoff(initial: CodableDuration, multiplier: UInt64, maxDelay: CodableDuration) func makeIterator() -> AnyIterator { switch self { @@ -127,16 +129,33 @@ extension REST { case let .constant(duration): return AnyIterator { - duration + duration.duration } case let .exponentialBackoff(initial, multiplier, maxDelay): return AnyIterator(ExponentialBackoff( - initial: initial, + initial: initial.duration, multiplier: multiplier, - maxDelay: maxDelay + maxDelay: maxDelay.duration )) } } } + + public struct CodableDuration: Codable, Equatable, Sendable { + public var seconds: Int64 + public var attoseconds: Int64 + + public var duration: Duration { + Duration(secondsComponent: seconds, attosecondsComponent: attoseconds) + } + + public static func seconds(_ seconds: Int) -> CodableDuration { + return CodableDuration(seconds: Int64(seconds), attoseconds: 0) + } + + public static func minutes(_ minutes: Int) -> CodableDuration { + return .seconds(minutes.saturatingMultiplication(60)) + } + } } diff --git a/ios/MullvadREST/Transport/APITransport.swift b/ios/MullvadREST/Transport/APITransport.swift new file mode 100644 index 000000000000..811e775a1950 --- /dev/null +++ b/ios/MullvadREST/Transport/APITransport.swift @@ -0,0 +1,51 @@ +// +// APITransport.swift +// MullvadVPNUITests +// +// Created by Jon Petersson on 2025-02-24. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import MullvadRustRuntime +import MullvadTypes + +public protocol APITransportProtocol { + var name: String { get } + + func sendRequest(_ request: APIRequest, completion: @escaping @Sendable (ProxyAPIResponse) -> Void) + -> Cancellable +} + +public final class APITransport: APITransportProtocol { + public var name: String { + "app-transport" + } + + public let requestFactory: MullvadApiRequestFactory + + public init(requestFactory: MullvadApiRequestFactory) { + self.requestFactory = requestFactory + } + + public func sendRequest( + _ request: APIRequest, + completion: @escaping @Sendable (ProxyAPIResponse) -> Void + ) -> Cancellable { + let apiRequest = requestFactory.makeRequest(request) + + return apiRequest { response in + let error: APIError? = if response.statusCode != 200 { + APIError( + statusCode: Int(response.statusCode), + errorDescription: response.errorDescription ?? "", + serverResponseCode: response.serverResponseCode + ) + } else { nil } + + completion(ProxyAPIResponse( + data: response.body, + error: error + )) + } + } +} diff --git a/ios/MullvadREST/Transport/APITransportProvider.swift b/ios/MullvadREST/Transport/APITransportProvider.swift new file mode 100644 index 000000000000..da5757d5115e --- /dev/null +++ b/ios/MullvadREST/Transport/APITransportProvider.swift @@ -0,0 +1,37 @@ +// +// APITransportProvider.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-02-24. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +public protocol APITransportProviderProtocol { + func makeTransport() -> APITransportProtocol? +} + +public final class APITransportProvider: APITransportProviderProtocol, Sendable { + let requestFactory: MullvadApiRequestFactory + + public init(requestFactory: MullvadApiRequestFactory) { + self.requestFactory = requestFactory + } + + public func makeTransport() -> APITransportProtocol? { + APITransport(requestFactory: requestFactory) + } +} + +extension REST { + public struct AnyAPITransportProvider: APITransportProviderProtocol { + private let block: () -> APITransportProtocol? + + public init(_ block: @escaping @Sendable () -> APITransportProtocol?) { + self.block = block + } + + public func makeTransport() -> APITransportProtocol? { + block() + } + } +} diff --git a/ios/MullvadRESTTests/RequestExecutorTests.swift b/ios/MullvadRESTTests/RequestExecutorTests.swift index 36b3ca2b3c63..636dca337909 100644 --- a/ios/MullvadRESTTests/RequestExecutorTests.swift +++ b/ios/MullvadRESTTests/RequestExecutorTests.swift @@ -25,10 +25,14 @@ final class RequestExecutorTests: XCTestCase { } } + let apiTransportProvider = REST.AnyAPITransportProvider { + APITransportStub() + } + let proxyFactory = REST.ProxyFactory.makeProxyFactory( transportProvider: transportProvider, - addressCache: addressCache, - apiContext: REST.apiContext + apiTransportProvider: apiTransportProvider, + addressCache: addressCache ) timerServerProxy = TimeServerProxy(configuration: proxyFactory.configuration) } @@ -76,3 +80,18 @@ final class RequestExecutorTests: XCTestCase { waitForExpectations(timeout: .UnitTest.timeout) } } + +extension RequestExecutorTests { + final class APITransportStub: APITransportProtocol, Sendable { + public var name: String { + "app-transport-dummy" + } + + public func sendRequest( + _ request: APIRequest, + completion: @escaping @Sendable (ProxyAPIResponse) -> Void + ) -> Cancellable { + AnyCancellable() + } + } +} diff --git a/ios/MullvadRESTTests/RetryStrategyTests.swift b/ios/MullvadRESTTests/RetryStrategyTests.swift index 2af2d2d47567..51e66132dd27 100644 --- a/ios/MullvadRESTTests/RetryStrategyTests.swift +++ b/ios/MullvadRESTTests/RetryStrategyTests.swift @@ -13,7 +13,7 @@ import XCTest class RetryStrategyTests: XCTestCase { func testJitteredBackoffDoesNotGoBeyondMaxDelay() throws { - let maxDelay = Duration(secondsComponent: 10, attosecondsComponent: 0) + let maxDelay = REST.CodableDuration(seconds: 10, attoseconds: 0) let retryDelay = REST.RetryDelay.exponentialBackoff(initial: .seconds(1), multiplier: 2, maxDelay: maxDelay) let retry = REST.RetryStrategy(maxRetryCount: 0, delay: retryDelay, applyJitter: true) let iterator = retry.makeDelayIterator() @@ -22,7 +22,7 @@ class RetryStrategyTests: XCTestCase { for _ in 0 ... 10 { let currentDelay = try XCTUnwrap(iterator.next()) XCTAssertLessThanOrEqual(previousDelay, currentDelay) - XCTAssertLessThanOrEqual(currentDelay, maxDelay) + XCTAssertLessThanOrEqual(currentDelay, maxDelay.duration) previousDelay = currentDelay } } diff --git a/ios/MullvadRustRuntime/MullvadApiCancellable.swift b/ios/MullvadRustRuntime/MullvadApiCancellable.swift index 0f0e0fe6e4be..6c76bc3c143e 100644 --- a/ios/MullvadRustRuntime/MullvadApiCancellable.swift +++ b/ios/MullvadRustRuntime/MullvadApiCancellable.swift @@ -6,7 +6,9 @@ // Copyright © 2025 Mullvad VPN AB. All rights reserved. // -public class MullvadApiCancellable { +import MullvadTypes + +public class MullvadApiCancellable: Cancellable { private let handle: SwiftCancelHandle public init(handle: consuming SwiftCancelHandle) { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 8a6ed1259cd5..aeb406512813 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -502,6 +502,11 @@ 7A28826A2BA8336600FD9F20 /* VPNSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */; }; 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */; }; 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */; }; + 7A2E7B702D6C9FCF009EF2C3 /* APITransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B672D6C9D7A009EF2C3 /* APITransport.swift */; }; + 7A2E7B712D6C9FE0009EF2C3 /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B6E2D6C9ED9009EF2C3 /* APIError.swift */; }; + 7A2E7B722D6C9FE5009EF2C3 /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B6C2D6C9E53009EF2C3 /* APIRequest.swift */; }; + 7A2E7B732D6C9FEB009EF2C3 /* APIRequestProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95B6742D5DF86400687524 /* APIRequestProxy.swift */; }; + 7A2E7B752D6CA0B1009EF2C3 /* APITransportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B742D6CA0AC009EF2C3 /* APITransportProvider.swift */; }; 7A307AD92A8CD8DA0017618B /* Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A307AD82A8CD8DA0017618B /* Duration.swift */; }; 7A307ADB2A8F56DF0017618B /* Duration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A307ADA2A8F56DF0017618B /* Duration+Extensions.swift */; }; 7A3215722D3934E6005DF395 /* MullvadApiCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3215702D392F0B005DF395 /* MullvadApiCompletion.swift */; }; @@ -598,8 +603,8 @@ 7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */; }; 7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */; }; 7A8A19282CF603EB000BCB5B /* SettingsViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */; }; - 7A95B6792D5F729300687524 /* DAITASettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95B6782D5F729300687524 /* DAITASettingsCoordinator.swift */; }; 7A95B67B2D5F758300687524 /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A95B67A2D5F758300687524 /* relays.json */; }; + 7A95B67D2D5F7C5B00687524 /* DAITASettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95B67C2D5F7C5B00687524 /* DAITASettingsCoordinator.swift */; }; 7A99D36F2D56070400891FF7 /* MullvadApiRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99D36E2D5606F900891FF7 /* MullvadApiRequestFactory.swift */; }; 7A99D3712D56222000891FF7 /* MullvadApiCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99D3702D56220E00891FF7 /* MullvadApiCancellable.swift */; }; 7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */; }; @@ -644,7 +649,7 @@ 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */; }; 7AB931242D43C2CA005FCEBA /* MullvadApiContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB931232D43C2C2005FCEBA /* MullvadApiContext.swift */; }; 7AB931262D43D22F005FCEBA /* MullvadApiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB931252D43D222005FCEBA /* MullvadApiResponse.swift */; }; - 7AB9312F2D4A5D0A005FCEBA /* RESTRustNetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB9312D2D4A5D0A005FCEBA /* RESTRustNetworkOperation.swift */; }; + 7AB9312F2D4A5D0A005FCEBA /* MullvadApiNetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB9312D2D4A5D0A005FCEBA /* MullvadApiNetworkOperation.swift */; }; 7ABCA5B32A9349F20044A708 /* Routing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; }; 7ABCA5B42A9349F20044A708 /* Routing.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7ABCA5B72A9353C60044A708 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CAF9F72983D36800BE19F7 /* Coordinator.swift */; }; @@ -2026,6 +2031,10 @@ 7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsCoordinator.swift; sourceTree = ""; }; 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = ""; }; 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = ""; }; + 7A2E7B672D6C9D7A009EF2C3 /* APITransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITransport.swift; sourceTree = ""; }; + 7A2E7B6C2D6C9E53009EF2C3 /* APIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequest.swift; sourceTree = ""; }; + 7A2E7B6E2D6C9ED9009EF2C3 /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; + 7A2E7B742D6CA0AC009EF2C3 /* APITransportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITransportProvider.swift; sourceTree = ""; }; 7A307AD82A8CD8DA0017618B /* Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Duration.swift; sourceTree = ""; }; 7A307ADA2A8F56DF0017618B /* Duration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extensions.swift"; sourceTree = ""; }; 7A3215702D392F0B005DF395 /* MullvadApiCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiCompletion.swift; sourceTree = ""; }; @@ -2111,8 +2120,9 @@ 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPage.swift; sourceTree = ""; }; 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITAPage.swift; sourceTree = ""; }; 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewControllerFactory.swift; sourceTree = ""; }; - 7A95B6782D5F729300687524 /* DAITASettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettingsCoordinator.swift; sourceTree = ""; }; + 7A95B6742D5DF86400687524 /* APIRequestProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequestProxy.swift; sourceTree = ""; }; 7A95B67A2D5F758300687524 /* relays.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = relays.json; sourceTree = ""; }; + 7A95B67C2D5F7C5B00687524 /* DAITASettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettingsCoordinator.swift; sourceTree = ""; }; 7A99D36E2D5606F900891FF7 /* MullvadApiRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiRequestFactory.swift; sourceTree = ""; }; 7A99D3702D56220E00891FF7 /* MullvadApiCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiCancellable.swift; sourceTree = ""; }; 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNodeTests.swift; sourceTree = ""; }; @@ -2154,7 +2164,7 @@ 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideInteractor.swift; sourceTree = ""; }; 7AB931232D43C2C2005FCEBA /* MullvadApiContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiContext.swift; sourceTree = ""; }; 7AB931252D43D222005FCEBA /* MullvadApiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiResponse.swift; sourceTree = ""; }; - 7AB9312D2D4A5D0A005FCEBA /* RESTRustNetworkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRustNetworkOperation.swift; sourceTree = ""; }; + 7AB9312D2D4A5D0A005FCEBA /* MullvadApiNetworkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiNetworkOperation.swift; sourceTree = ""; }; 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = ""; }; 7ABFB09D2BA316220074A49E /* RelayConstraintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConstraintsTests.swift; sourceTree = ""; }; 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; @@ -2662,6 +2672,7 @@ isa = PBXGroup; children = ( F06045F02B2324DA00B2D37A /* ApiHandlers */, + 7A2E7B6B2D6C9E45009EF2C3 /* APIRequest */, 062B45A228FD4C0F00746E77 /* Assets */, 7AD63A422CDA661B00445268 /* Extensions */, 582FFA82290A84E700895745 /* Info.plist */, @@ -4139,6 +4150,16 @@ path = Alert; sourceTree = ""; }; + 7A2E7B6B2D6C9E45009EF2C3 /* APIRequest */ = { + isa = PBXGroup; + children = ( + 7A2E7B6E2D6C9ED9009EF2C3 /* APIError.swift */, + 7A2E7B6C2D6C9E53009EF2C3 /* APIRequest.swift */, + 7A95B6742D5DF86400687524 /* APIRequestProxy.swift */, + ); + path = APIRequest; + sourceTree = ""; + }; 7A45CFCD2C08697100D80B21 /* Screenshots */ = { isa = PBXGroup; children = ( @@ -4242,7 +4263,7 @@ 7A8A19082CE5FFD7000BCB5B /* DAITA */ = { isa = PBXGroup; children = ( - 7A95B6782D5F729300687524 /* DAITASettingsCoordinator.swift */, + 7A95B67C2D5F7C5B00687524 /* DAITASettingsCoordinator.swift */, F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */, 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */, @@ -4505,6 +4526,7 @@ 06AC114128F8413A0037AF9A /* AddressCache.swift */, A935594B2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift */, 06FAE67128F83CA40033DD93 /* HTTP.swift */, + 7AB9312D2D4A5D0A005FCEBA /* MullvadApiNetworkOperation.swift */, 06FAE67228F83CA40033DD93 /* RESTAccessTokenManager.swift */, 06FAE66828F83CA30033DD93 /* RESTAccountsProxy.swift */, 06FAE67328F83CA40033DD93 /* RESTAPIProxy.swift */, @@ -4521,7 +4543,6 @@ 06FAE66A28F83CA30033DD93 /* RESTRequestFactory.swift */, 06FAE67428F83CA40033DD93 /* RESTRequestHandler.swift */, 06FAE66628F83CA30033DD93 /* RESTResponseHandler.swift */, - 7AB9312D2D4A5D0A005FCEBA /* RESTRustNetworkOperation.swift */, 06FAE67528F83CA40033DD93 /* RESTTaskIdentifier.swift */, 06FAE66528F83CA30033DD93 /* RESTURLSession.swift */, 7A99D36E2D5606F900891FF7 /* MullvadApiRequestFactory.swift */, @@ -4636,6 +4657,8 @@ isa = PBXGroup; children = ( F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */, + 7A2E7B672D6C9D7A009EF2C3 /* APITransport.swift */, + 7A2E7B742D6CA0AC009EF2C3 /* APITransportProvider.swift */, F0DC77A32B2315800087F09D /* Direct */, F0E5B2F62C9C689C0007F78C /* EncryptedDNS */, A932D9EE2B5ADD0700999395 /* ProxyConfigurationTransportProvider.swift */, @@ -5664,6 +5687,7 @@ 06799AE228F98E4800ACD94E /* RESTRequestFactory.swift in Sources */, A90763BD2B2857D50045ADF0 /* Socks5Connection.swift in Sources */, 06799AEC28F98E4800ACD94E /* RESTTaskIdentifier.swift in Sources */, + 7A2E7B712D6C9FE0009EF2C3 /* APIError.swift in Sources */, 58E7BA192A975DF70068EC3A /* RESTTransportProvider.swift in Sources */, F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */, 06799ADE28F98E4800ACD94E /* RESTRequestHandler.swift in Sources */, @@ -5673,6 +5697,7 @@ 7A4D849E2C0F289800687980 /* RelaySelectorProtocol.swift in Sources */, F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */, A9A1DE792AD5708E0073F689 /* TransportStrategy.swift in Sources */, + 7A2E7B732D6C9FEB009EF2C3 /* APIRequestProxy.swift in Sources */, A90763BF2B2857D50045ADF0 /* Socks5Handshake.swift in Sources */, 7AFBE38D2D09AB2E002335FC /* MultihopPicker.swift in Sources */, 7A3AD5012C1068A800E9AD90 /* RelayPicking.swift in Sources */, @@ -5695,6 +5720,8 @@ 06799AF228F98E4800ACD94E /* RESTAccessTokenManager.swift in Sources */, A90763B12B2857D50045ADF0 /* Socks5Endpoint.swift in Sources */, 7AFBE38B2D09AAFF002335FC /* SinglehopPicker.swift in Sources */, + 7A2E7B702D6C9FCF009EF2C3 /* APITransport.swift in Sources */, + 7A2E7B752D6CA0B1009EF2C3 /* APITransportProvider.swift in Sources */, 06799AF328F98E4800ACD94E /* RESTAuthenticationProxy.swift in Sources */, F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */, 7AD63A442CDA663300445268 /* UInt+Counting.swift in Sources */, @@ -5710,7 +5737,7 @@ F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */, F0DDE42C2B220A15006B57A7 /* Midpoint.swift in Sources */, A90763C72B2858DC0045ADF0 /* CancellableChain.swift in Sources */, - 7AB9312F2D4A5D0A005FCEBA /* RESTRustNetworkOperation.swift in Sources */, + 7AB9312F2D4A5D0A005FCEBA /* MullvadApiNetworkOperation.swift in Sources */, 06799AF128F98E4800ACD94E /* RESTAPIProxy.swift in Sources */, F0DDE42A2B220A15006B57A7 /* Haversine.swift in Sources */, 589E76C02A9378F100E502F3 /* RESTRequestExecutor.swift in Sources */, @@ -5737,6 +5764,7 @@ 06799AE428F98E4800ACD94E /* RESTAccountsProxy.swift in Sources */, 5897F1742913EAF800AF5695 /* ExponentialBackoff.swift in Sources */, 44CAEAA12D442F5E004A8E65 /* LocationIdentifier.swift in Sources */, + 7A2E7B722D6C9FE5009EF2C3 /* APIRequest.swift in Sources */, 06799AE328F98E4800ACD94E /* RESTNetworkOperation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6073,7 +6101,7 @@ 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */, 7A6389DC2B7E3BD6008E77E1 /* CustomListViewModel.swift in Sources */, 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */, - 7A95B6792D5F729300687524 /* DAITASettingsCoordinator.swift in Sources */, + 7A95B67D2D5F7C5B00687524 /* DAITASettingsCoordinator.swift in Sources */, 7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */, 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */, 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */, diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 5e30f2fae96c..c88f37ba4bf6 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD nonisolated(unsafe) private(set) var relayCacheTracker: RelayCacheTracker! nonisolated(unsafe) private(set) var storePaymentManager: StorePaymentManager! nonisolated(unsafe) private var transportMonitor: TransportMonitor! + nonisolated(unsafe) private var apiTransportMonitor: APITransportMonitor! private var settingsObserver: TunnelBlockObserver! private var migrationManager: MigrationManager! @@ -155,8 +156,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD transportStrategy: transportStrategy, encryptedDNSTransport: encryptedDNSTransport ) + + let apiRequestFactory = MullvadApiRequestFactory(apiContext: REST.apiContext) + let apiTransportProvider = APITransportProvider(requestFactory: apiRequestFactory) + + apiTransportMonitor = APITransportMonitor( + tunnelManager: tunnelManager, + tunnelStore: tunnelStore, + requestFactory: apiRequestFactory + ) + setUpTransportMonitor(transportProvider: transportProvider) - setUpSimulatorHost(transportProvider: transportProvider, relaySelector: relaySelector) + setUpSimulatorHost( + transportProvider: transportProvider, + apiTransportProvider: apiTransportProvider, + relaySelector: relaySelector + ) registerBackgroundTasks() setupPaymentHandler() @@ -188,18 +203,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if launchArguments.target == .screenshots { proxyFactory = MockProxyFactory.makeProxyFactory( transportProvider: REST.AnyTransportProvider { [weak self] in - return self?.transportMonitor.makeTransport() + self?.transportMonitor.makeTransport() + }, + apiTransportProvider: REST.AnyAPITransportProvider { [weak self] in + self?.apiTransportMonitor.makeTransport() }, - addressCache: addressCache, - apiContext: REST.apiContext + addressCache: addressCache ) } else { proxyFactory = REST.ProxyFactory.makeProxyFactory( transportProvider: REST.AnyTransportProvider { [weak self] in - return self?.transportMonitor.makeTransport() + self?.transportMonitor.makeTransport() }, - addressCache: addressCache, - apiContext: REST.apiContext + apiTransportProvider: REST.AnyAPITransportProvider { [weak self] in + self?.apiTransportMonitor.makeTransport() + }, + addressCache: addressCache ) } apiProxy = proxyFactory.createAPIProxy() @@ -217,13 +236,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func setUpSimulatorHost( transportProvider: TransportProvider, + apiTransportProvider: APITransportProvider, relaySelector: RelaySelectorWrapper ) { #if targetEnvironment(simulator) // Configure mock tunnel provider on simulator simulatorTunnelProviderHost = SimulatorTunnelProviderHost( relaySelector: relaySelector, - transportProvider: transportProvider + transportProvider: transportProvider, + apiTransportProvider: apiTransportProvider ) SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost #endif diff --git a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift index 53518534720d..75a92e1b03d5 100644 --- a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift +++ b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift @@ -20,17 +20,26 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche private var observedState: ObservedState = .disconnected private var selectedRelays: SelectedRelays? private let urlRequestProxy: URLRequestProxy + private let apiRequestProxy: APIRequestProxy private let relaySelector: RelaySelectorProtocol private let providerLogger = Logger(label: "SimulatorTunnelProviderHost") private let dispatchQueue = DispatchQueue(label: "SimulatorTunnelProviderHostQueue") - init(relaySelector: RelaySelectorProtocol, transportProvider: TransportProvider) { + init( + relaySelector: RelaySelectorProtocol, + transportProvider: TransportProvider, + apiTransportProvider: APITransportProvider + ) { self.relaySelector = relaySelector self.urlRequestProxy = URLRequestProxy( dispatchQueue: dispatchQueue, transportProvider: transportProvider ) + self.apiRequestProxy = APIRequestProxy( + dispatchQueue: dispatchQueue, + transportProvider: apiTransportProvider + ) } override func startTunnel( @@ -153,11 +162,30 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche handler?(reply) } + case let .sendAPIRequest(proxyRequest): + apiRequestProxy.sendRequest(proxyRequest) { response in + var reply: Data? + do { + reply = try TunnelProviderReply(response).encode() + } catch { + self.providerLogger.error( + error: error, + message: "Failed to encode ProxyURLResponse." + ) + } + handler?(reply) + } + case let .cancelURLRequest(listId): urlRequestProxy.cancelRequest(identifier: listId) completionHandler?(nil) + case let .cancelAPIRequest(listId): + apiRequestProxy.cancelRequest(identifier: listId) + + completionHandler?(nil) + case .privateKeyRotation: completionHandler?(nil) } diff --git a/ios/MullvadVPN/TransportMonitor/PacketTunnelTransport.swift b/ios/MullvadVPN/TransportMonitor/PacketTunnelTransport.swift index 3c40c7d75a47..ee788e5e7db3 100644 --- a/ios/MullvadVPN/TransportMonitor/PacketTunnelTransport.swift +++ b/ios/MullvadVPN/TransportMonitor/PacketTunnelTransport.swift @@ -8,6 +8,7 @@ import Foundation import MullvadREST +import MullvadRustRuntime import MullvadTypes import Operations import PacketTunnelCore @@ -55,3 +56,40 @@ struct PacketTunnelTransport: RESTTransport { } } } + +final class PacketTunnelAPITransport: APITransportProtocol { + var name: String { + "packet-tunnel-transport" + } + + let tunnel: any TunnelProtocol + + init(tunnel: any TunnelProtocol) { + self.tunnel = tunnel + } + + func sendRequest( + _ request: APIRequest, + completion: @escaping @Sendable (ProxyAPIResponse) -> Void + ) -> Cancellable { + let proxyRequest = ProxyAPIRequest( + id: UUID(), + request: request + ) + + return tunnel.sendAPIRequest(proxyRequest) { result in + switch result { + case let .success(reply): + completion(reply) + + case let .failure(error): + let error = error.isOperationCancellationError ? URLError(.cancelled) : error + completion(ProxyAPIResponse(data: nil, error: APIError( + statusCode: 0, + errorDescription: error.localizedDescription, + serverResponseCode: nil + ))) + } + } + } +} diff --git a/ios/MullvadVPN/TransportMonitor/TransportMonitor.swift b/ios/MullvadVPN/TransportMonitor/TransportMonitor.swift index 3335352de274..1d0472783bab 100644 --- a/ios/MullvadVPN/TransportMonitor/TransportMonitor.swift +++ b/ios/MullvadVPN/TransportMonitor/TransportMonitor.swift @@ -60,3 +60,44 @@ final class TransportMonitor: RESTTransportProvider { } } } + +final class APITransportMonitor: APITransportProviderProtocol { + private let tunnelManager: TunnelManager + private let tunnelStore: TunnelStore + private let requestFactory: MullvadApiRequestFactory + + init(tunnelManager: TunnelManager, tunnelStore: TunnelStore, requestFactory: MullvadApiRequestFactory) { + self.tunnelManager = tunnelManager + self.tunnelStore = tunnelStore + self.requestFactory = requestFactory + } + + func makeTransport() -> APITransportProtocol? { + let tunnel = tunnelStore.getPersistentTunnels().first { tunnel in + tunnel.status == .connecting || tunnel.status == .reasserting || tunnel.status == .connected + } + + return if let tunnel, shouldBypassVPN(tunnel: tunnel) { + PacketTunnelAPITransport(tunnel: tunnel) + } else { + APITransport(requestFactory: requestFactory) + } + } + + private func shouldBypassVPN(tunnel: any TunnelProtocol) -> Bool { + switch tunnel.status { + case .connected: + if case .error = tunnelManager.tunnelStatus.state { + true + } else { + tunnelManager.isConfigurationLoaded && tunnelManager.deviceState == .revoked + } + + case .connecting, .reasserting: + true + + default: + false + } + } +} diff --git a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift index 2f31f2f3e848..71d823854568 100644 --- a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift +++ b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift @@ -109,6 +109,49 @@ extension TunnelProtocol { return operation } + /// Send API request via packet tunnel process bypassing VPN. + func sendAPIRequest( + _ proxyRequest: ProxyAPIRequest, + completionHandler: @escaping @Sendable (Result) -> Void + ) -> Cancellable { + let decoderHandler: (Data?) throws -> ProxyAPIResponse = { data in + if let data { + return try TunnelProviderReply(messageData: data).value + } else { + throw EmptyTunnelProviderResponseError() + } + } + + let operation = SendTunnelProviderMessageOperation( + dispatchQueue: dispatchQueue, + backgroundTaskProvider: backgroundTaskProvider, + tunnel: self, + message: .sendAPIRequest(proxyRequest), + timeout: proxyRequestTimeout, + decoderHandler: decoderHandler, + completionHandler: completionHandler + ) + + operation.onCancel { [weak self] _ in + guard let self else { return } + + let cancelOperation = SendTunnelProviderMessageOperation( + dispatchQueue: dispatchQueue, + backgroundTaskProvider: backgroundTaskProvider, + tunnel: self, + message: .cancelAPIRequest(proxyRequest.id), + decoderHandler: decoderHandler, + completionHandler: nil + ) + + operationQueue.addOperation(cancelOperation) + } + + operationQueue.addOperation(operation) + + return operation + } + /// Notify tunnel about private key rotation. func notifyKeyRotation( completionHandler: @escaping @Sendable (Result) -> Void diff --git a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift index 0c21fdacc694..12e1b933e924 100644 --- a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift +++ b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift @@ -147,7 +147,10 @@ class TunnelManagerTests: XCTestCase { let simulatorTunnelProviderHost = SimulatorTunnelProviderHost( relaySelector: relaySelector, - transportProvider: transportProvider + transportProvider: transportProvider, + apiTransportProvider: APITransportProvider( + requestFactory: MullvadApiRequestFactory(apiContext: REST.apiContext) + ) ) SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost @@ -215,18 +218,18 @@ class TunnelManagerTests: XCTestCase { let simulatorTunnelProviderHost = SimulatorTunnelProviderHost( relaySelector: relaySelector, - transportProvider: transportProvider + transportProvider: transportProvider, + apiTransportProvider: APITransportProvider( + requestFactory: MullvadApiRequestFactory(apiContext: REST.apiContext) + ) ) SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost let tunnelObserver = TunnelBlockObserver( didUpdateTunnelStatus: { _, tunnelStatus in switch tunnelStatus.state { - case .connected: - connectedExpectation.fulfill() - case .disconnected: - disconnectedExpectation.fulfill() - default: - return + case .connected: connectedExpectation.fulfill() + case .disconnected: disconnectedExpectation.fulfill() + default: return } } ) @@ -240,8 +243,7 @@ class TunnelManagerTests: XCTestCase { tunnelManager.startTunnel() await fulfillment(of: [connectedExpectation]) - tunnelManager - .reapplyTunnelConfiguration() + tunnelManager.reapplyTunnelConfiguration() connectedExpectation = expectation(description: "Connected!") await fulfillment( of: [disconnectedExpectation, connectedExpectation], diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 228a6f9d9e35..4b943396a6cf 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -64,6 +64,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { addressCache: addressCache ) + let apiTransportProvider = APITransportProvider( + requestFactory: MullvadApiRequestFactory(apiContext: REST.apiContext) + ) + adapter = WgAdapter(packetTunnelProvider: self) let pinger = TunnelPinger(pingProvider: adapter.icmpPingProvider, replyQueue: internalQueue) @@ -77,8 +81,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { let proxyFactory = REST.ProxyFactory.makeProxyFactory( transportProvider: transportProvider, - addressCache: addressCache, - apiContext: REST.apiContext + apiTransportProvider: apiTransportProvider, + addressCache: addressCache ) let accountsProxy = proxyFactory.createAccountsProxy() let devicesProxy = proxyFactory.createDevicesProxy() @@ -102,8 +106,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { protocolObfuscator: ProtocolObfuscator() ) - let urlRequestProxy = URLRequestProxy(dispatchQueue: internalQueue, transportProvider: transportProvider) - appMessageHandler = AppMessageHandler(packetTunnelActor: actor, urlRequestProxy: urlRequestProxy) + let urlRequestProxy = URLRequestProxy( + dispatchQueue: internalQueue, + transportProvider: transportProvider + ) + let apiRequestProxy = APIRequestProxy( + dispatchQueue: internalQueue, + transportProvider: apiTransportProvider + ) + appMessageHandler = AppMessageHandler( + packetTunnelActor: actor, + urlRequestProxy: urlRequestProxy, + apiRequestProxy: apiRequestProxy + ) ephemeralPeerExchangingPipeline = EphemeralPeerExchangingPipeline( EphemeralPeerExchangeActor( diff --git a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift index f3473fed6772..fc99fa7c2299 100644 --- a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift +++ b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift @@ -8,6 +8,7 @@ import Foundation import MullvadLogging +import MullvadREST /** Actor handling packet tunnel IPC (app) messages and patching them through to the right facility. @@ -16,10 +17,16 @@ public struct AppMessageHandler { private let logger = Logger(label: "AppMessageHandler") private let packetTunnelActor: PacketTunnelActorProtocol private let urlRequestProxy: URLRequestProxyProtocol + private let apiRequestProxy: APIRequestProxyProtocol - public init(packetTunnelActor: PacketTunnelActorProtocol, urlRequestProxy: URLRequestProxyProtocol) { + public init( + packetTunnelActor: PacketTunnelActorProtocol, + urlRequestProxy: URLRequestProxyProtocol, + apiRequestProxy: APIRequestProxyProtocol + ) { self.packetTunnelActor = packetTunnelActor self.urlRequestProxy = urlRequestProxy + self.apiRequestProxy = apiRequestProxy } /** @@ -42,10 +49,17 @@ public struct AppMessageHandler { case let .sendURLRequest(request): return await encodeReply(urlRequestProxy.sendRequest(request)) + case let .sendAPIRequest(request): + return await encodeReply(apiRequestProxy.sendRequest(request)) + case let .cancelURLRequest(id): urlRequestProxy.cancelRequest(identifier: id) return nil + case let .cancelAPIRequest(id): + apiRequestProxy.cancelRequest(identifier: id) + return nil + case .getTunnelStatus: return await encodeReply(packetTunnelActor.observedState) diff --git a/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift b/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift index 955f119dbe2f..2ba2691ed37a 100644 --- a/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift +++ b/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadREST /// Enum describing supported app messages handled by packet tunnel provider. public enum TunnelProviderMessage: Codable, CustomStringConvertible { @@ -19,9 +20,15 @@ public enum TunnelProviderMessage: Codable, CustomStringConvertible { /// Send HTTP request outside of VPN tunnel. case sendURLRequest(ProxyURLRequest) + /// Send API request outside of VPN tunnel. + case sendAPIRequest(ProxyAPIRequest) + /// Cancel HTTP request sent outside of VPN tunnel. case cancelURLRequest(UUID) + /// Cancel API request sent outside of VPN tunnel. + case cancelAPIRequest(UUID) + /// Notify tunnel about private key rotation. case privateKeyRotation @@ -33,8 +40,12 @@ public enum TunnelProviderMessage: Codable, CustomStringConvertible { return "get-tunnel-status" case .sendURLRequest: return "send-http-request" + case .sendAPIRequest: + return "send-api-request" case .cancelURLRequest: return "cancel-http-request" + case .cancelAPIRequest: + return "cancel-api-request" case .privateKeyRotation: return "private-key-rotation" } diff --git a/ios/PacketTunnelCore/URLRequestProxy/ProxyURLResponse.swift b/ios/PacketTunnelCore/URLRequestProxy/ProxyURLResponse.swift index 3c2a2e797baa..e2c6e5065e1c 100644 --- a/ios/PacketTunnelCore/URLRequestProxy/ProxyURLResponse.swift +++ b/ios/PacketTunnelCore/URLRequestProxy/ProxyURLResponse.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadREST /// Struct describing serializable URLResponse data. public struct ProxyURLResponse: Codable, Sendable { diff --git a/ios/PacketTunnelCore/URLRequestProxy/URLRequestProxyProtocol.swift b/ios/PacketTunnelCore/URLRequestProxy/URLRequestProxyProtocol.swift index 300de8a4793b..b848f48dde31 100644 --- a/ios/PacketTunnelCore/URLRequestProxy/URLRequestProxyProtocol.swift +++ b/ios/PacketTunnelCore/URLRequestProxy/URLRequestProxyProtocol.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadREST public protocol URLRequestProxyProtocol { func sendRequest(_ proxyRequest: ProxyURLRequest, completionHandler: @escaping @Sendable (ProxyURLResponse) -> Void) diff --git a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift index 4b652f179fd7..16332271ed34 100644 --- a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift +++ b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift @@ -14,6 +14,8 @@ import PacketTunnelCore import XCTest final class AppMessageHandlerTests: XCTestCase { + // MARK: URLRequest + func testHandleAppMessageForSendURLRequest() async throws { let sendRequestExpectation = expectation(description: "Expect sending request") @@ -46,6 +48,41 @@ final class AppMessageHandlerTests: XCTestCase { await fulfillment(of: [cancelRequestExpectation], timeout: .UnitTest.timeout) } + // MARK: APIRequest + + func testHandleAppMessageForSendAPIRequest() async throws { + let sendRequestExpectation = expectation(description: "Expect sending request") + + let apiRequestProxy = APIRequestProxyStub(sendRequestExpectation: sendRequestExpectation) + let appMessageHandler = createAppMessageHandler(apiRequestProxy: apiRequestProxy) + + let apiRequest = ProxyAPIRequest( + id: UUID(), + request: .getAddressList(.default) + ) + + _ = try? await appMessageHandler.handleAppMessage( + TunnelProviderMessage.sendAPIRequest(apiRequest).encode() + ) + + await fulfillment(of: [sendRequestExpectation], timeout: .UnitTest.timeout) + } + + func testHandleAppMessageForCancelAPIRequest() async throws { + let cancelRequestExpectation = expectation(description: "Expect cancelling request") + + let apiRequestProxy = APIRequestProxyStub(cancelRequestExpectation: cancelRequestExpectation) + let appMessageHandler = createAppMessageHandler(apiRequestProxy: apiRequestProxy) + + _ = try? await appMessageHandler.handleAppMessage( + TunnelProviderMessage.cancelAPIRequest(UUID()).encode() + ) + + await fulfillment(of: [cancelRequestExpectation], timeout: .UnitTest.timeout) + } + + // MARK: Other + func testHandleAppMessageForTunnelStatus() async throws { let stateExpectation = expectation(description: "Expect getting state") @@ -117,11 +154,13 @@ final class AppMessageHandlerTests: XCTestCase { extension AppMessageHandlerTests { func createAppMessageHandler( actor: PacketTunnelActorProtocol = PacketTunnelActorStub(), - urlRequestProxy: URLRequestProxyProtocol = URLRequestProxyStub() + urlRequestProxy: URLRequestProxyProtocol = URLRequestProxyStub(), + apiRequestProxy: APIRequestProxyProtocol = APIRequestProxyStub() ) -> AppMessageHandler { return AppMessageHandler( packetTunnelActor: actor, - urlRequestProxy: urlRequestProxy + urlRequestProxy: urlRequestProxy, + apiRequestProxy: apiRequestProxy ) } } diff --git a/ios/PacketTunnelCoreTests/Mocks/URLRequestProxyStub.swift b/ios/PacketTunnelCoreTests/Mocks/URLRequestProxyStub.swift index 554ebbb3bcbe..847e0ae09034 100644 --- a/ios/PacketTunnelCoreTests/Mocks/URLRequestProxyStub.swift +++ b/ios/PacketTunnelCoreTests/Mocks/URLRequestProxyStub.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadREST import PacketTunnelCore import XCTest @@ -30,3 +31,24 @@ struct URLRequestProxyStub: URLRequestProxyProtocol { cancelRequestExpectation?.fulfill() } } + +struct APIRequestProxyStub: APIRequestProxyProtocol { + var sendRequestExpectation: XCTestExpectation? + var cancelRequestExpectation: XCTestExpectation? + + func sendRequest( + _ proxyRequest: ProxyAPIRequest, + completion: @escaping @Sendable (ProxyAPIResponse) -> Void + ) { + sendRequestExpectation?.fulfill() + } + + func sendRequest(_ proxyRequest: ProxyAPIRequest) async -> ProxyAPIResponse { + sendRequestExpectation?.fulfill() + return ProxyAPIResponse(data: nil, error: nil) + } + + func cancelRequest(identifier: UUID) { + cancelRequestExpectation?.fulfill() + } +}