diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-07-10 15:58:30 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2020-07-22 18:35:43 +0300 |
| commit | c44e4c297769b3587929c6505e6ef5f7e1d58f95 (patch) | |
| tree | 61e01becf6220fb52f989f6d7b6da4a3be56499b | |
| parent | f10dc9875b3d8e5d35448af2a9c58b9db07b8829 (diff) | |
| download | mullvadvpn-c44e4c297769b3587929c6505e6ef5f7e1d58f95.tar.xz mullvadvpn-c44e4c297769b3587929c6505e6ef5f7e1d58f95.zip | |
Add MullvadRest implementation
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | ios/MullvadVPN/MullvadRest.swift | 509 |
2 files changed, 516 insertions, 1 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 8fa3f3734e..9d2adfa428 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -140,6 +140,8 @@ 58C6B36122C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */; }; 58C6B36522C10596003C19AD /* AnyIPEndpoint+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */; }; 58C6B36722C106FC003C19AD /* WireguardCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36622C106FC003C19AD /* WireguardCommand.swift */; }; + 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */; }; + 58CB0EE124B86751001EF0D8 /* MullvadRest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */; }; 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; 58CC40F024A602780019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* ConnectViewController.swift */; }; @@ -316,6 +318,7 @@ 58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+DNS64.swift"; sourceTree = "<group>"; }; 58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+Wireguard.swift"; sourceTree = "<group>"; }; 58C6B36622C106FC003C19AD /* WireguardCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireguardCommand.swift; sourceTree = "<group>"; }; + 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadRest.swift; sourceTree = "<group>"; }; 58CC40EE24A601900019D96E /* ObserverList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverList.swift; sourceTree = "<group>"; }; 58CCA00F224249A1004F3011 /* ConnectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewController.swift; sourceTree = "<group>"; }; 58CCA01122424D11004F3011 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; }; @@ -505,6 +508,7 @@ 5840250022B1124600E4CFEC /* IpAddress+Codable.swift */, 58C6B34E22BB7AC0003C19AD /* IPAddressRange.swift */, 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */, + 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */, 58FAEDF6245088E100CB0F5B /* Keychain.swift */, 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */, 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */, @@ -518,6 +522,7 @@ 58CE5E65224146200008646E /* LoginViewController.swift */, 58CE5E67224146200008646E /* Main.storyboard */, 5840250322B11AB700E4CFEC /* MullvadEndpoint.swift */, + 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */, 58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */, 58FBDAAA22A52DC500EB69A3 /* MullvadVPN-Bridging-Header.h */, 5866F39B2243B82D00168AE5 /* MullvadVPN.entitlements */, @@ -526,7 +531,6 @@ 58CC40EE24A601900019D96E /* ObserverList.swift */, 580EE1FF24B3218800F9D8A1 /* Operations */, 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */, - 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */, 58BFA5C522A7C97F00A6173D /* RelayCache.swift */, 58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */, 5888AD88227B18C40051EB06 /* RelayList.swift */, @@ -904,6 +908,7 @@ 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */, 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */, 58C6B35422BB87C4003C19AD /* WireguardPrivateKey.swift in Sources */, + 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */, 580EE20924B3224200F9D8A1 /* RetryOperation.swift in Sources */, 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */, 58FAEDF7245088E100CB0F5B /* Keychain.swift in Sources */, @@ -986,6 +991,7 @@ files = ( 5860F1C423A8D25F00CEA666 /* WireguardConfiguration.swift in Sources */, 58F3C09D249B99DD003E76BE /* Curve25519.swift in Sources */, + 58CB0EE124B86751001EF0D8 /* MullvadRest.swift in Sources */, 580EE21F24B3237F00F9D8A1 /* OutputOperation.swift in Sources */, 580EE20224B321DB00F9D8A1 /* OperationProtocol.swift in Sources */, 58FAEE0224533ABB00CB0F5B /* KeychainMatchLimit.swift in Sources */, diff --git a/ios/MullvadVPN/MullvadRest.swift b/ios/MullvadVPN/MullvadRest.swift new file mode 100644 index 0000000000..7d587f52d6 --- /dev/null +++ b/ios/MullvadVPN/MullvadRest.swift @@ -0,0 +1,509 @@ +// +// MullvadRest.swift +// MullvadVPN +// +// Created by pronebird on 10/07/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Network + +/// REST API v1 base URL +private let kRestBaseURL = URL(string: "https://api.mullvad.net/app/v1")! + +/// Network request timeout in seconds +private let kNetworkTimeout: TimeInterval = 10 + +/// HTTP method +enum HttpMethod: String { + case get = "GET" + case post = "POST" + case delete = "DELETE" +} + +/// A known list of Rest API error codes +enum RestErrorCode: String { + case invalidAccount = "INVALID_ACCOUNT" + case keyLimitReached = "KEY_LIMIT_REACHED" +} + +/// A struct that represents a server response in case of error (any HTTP status code except 2xx). +struct ServerErrorResponse: LocalizedError, Decodable, RestResponse { + let code: String + let error: String? + + var errorDescription: String? { + switch code { + case RestErrorCode.keyLimitReached.rawValue: + return NSLocalizedString("Too many public WireGuard keys", comment: "") + case RestErrorCode.invalidAccount.rawValue: + return NSLocalizedString("Invalid account", comment: "") + default: + return nil + } + } + + var recoverySuggestion: String? { + switch code { + case RestErrorCode.keyLimitReached.rawValue: + return NSLocalizedString("Remove unused WireGuard keys", comment: "") + default: + return nil + } + } +} + +/// An error type returned by `MullvadRest` +enum RestError: ChainedError { + /// A failure to encode the payload + case encodePayload(Error) + + /// A failure during networking + case network(URLError) + + /// A failure reported by server + case server(ServerErrorResponse) + + /// A failure to decode the error response from server + case decodeErrorResponse(Error) + + /// A failure to decode the success response from server + case decodeSuccessResponse(Error) + + var errorDescription: String? { + switch self { + case .encodePayload: + return "Failure to encode the payload" + case .network: + return "Network error" + case .server: + return "Server error" + case .decodeErrorResponse: + return "Failure to decode error response from server" + case .decodeSuccessResponse: + return "Failure to decode success response from server" + } + } +} + +/// Types conforming to this protocol can participate in forming the `URLRequest` created by +/// `RestEndpoint`. +protocol RestPayload { + func inject(into request: inout URLRequest) throws +} + +/// Types conforming to this protocol can act as REST response types. +protocol RestResponse { + associatedtype Output + + static func decodeResponse(_ data: Data) throws -> Output +} + +/// Any `Decodable` can be REST response +extension Decodable where Self: RestResponse { + static func decodeResponse(_ data: Data) throws -> Self { + try MullvadRest.makeJSONDecoder().decode(Self.self, from: data) + } +} + +/// An empty REST response type that cannot be instantiated and is only used to produce an empty +/// output. +enum EmptyResponse {} +extension EmptyResponse: RestResponse { + static func decodeResponse(_ data: Data) throws -> () { + return () + } +} + +/// Any `Encodable` type can be injected as JSON payload +extension RestPayload where Self: Encodable { + func inject(into request: inout URLRequest) throws { + request.httpBody = try MullvadRest.makeJSONEncoder().encode(self) + } +} + +// MARK: - Operations + +final class RestOperation<Input, Response>: AsyncOperation, InputOperation, OutputOperation + where Input: RestPayload, Response: RestResponse +{ + typealias Output = Result<Response.Output, RestError> + + private let endpoint: RestEndpoint<Input, Response> + private let session: URLSession + private var task: URLSessionTask? + + init(endpoint: RestEndpoint<Input, Response>, session: URLSession, input: Input? = nil) { + self.endpoint = endpoint + self.session = session + + super.init() + self.input = input + } + + override func main() { + guard let payload = self.input else { + finish() + return + } + + let result = endpoint.dataTask(session: session, payload: payload) { [weak self] (result) in + self?.finish(with: result) + } + + switch result { + case .success(let task): + self.task = task + task.resume() + case .failure(let error): + finish(with: .failure(error)) + } + } + + override func operationDidCancel() { + task?.cancel() + task = nil + } +} + +// MARK: - Endpoints + +/// A struct that describes the REST endpoint, including the expected input and output +struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse { + let endpointURL: URL + let httpMethod: HttpMethod + + init(endpointURL: URL, httpMethod: HttpMethod) { + self.endpointURL = endpointURL + self.httpMethod = httpMethod + } + + /// Create `URLSessionDataTask` that automatically parses the HTTP response and returns the + /// expected response type or error upon completion. + func dataTask(session: URLSession, payload: Input, completionHandler: @escaping (Result<Response.Output, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { + return makeURLRequest(payload: payload).map { (request) -> URLSessionDataTask in + return session.dataTask(with: request) { (responseData, urlResponse, error) in + let result = Self.handleURLResponse(urlResponse, data: responseData, error: error) + completionHandler(result) + } + } + } + + /// Create `RestOperation` that automatically parses the response and sets the expected output + /// type or error upon completion. + func operation(session: URLSession, payload: Input?) -> RestOperation<Input, Response> { + return RestOperation(endpoint: self, session: session, input: payload) + } + + /// Create `URLRequest` that can be used to send an HTTP request + private func makeURLRequest(payload: Input) -> Result<URLRequest, RestError> { + var request = makeEndpointURLRequest() + do { + try payload.inject(into: &request) + + return .success(request) + } catch { + return .failure(.encodePayload(error)) + } + } + + /// Create a boilerplate `URLRequest` before injecting the payload + private func makeEndpointURLRequest() -> URLRequest { + var request = URLRequest( + url: endpointURL, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: kNetworkTimeout + ) + request.httpShouldHandleCookies = false + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpMethod = httpMethod.rawValue + return request + } + + /// A private HTTP response handler + private static func handleURLResponse(_ urlResponse: URLResponse?, data: Data?, error: Error?) -> Result<Response.Output, RestError> { + if let error = error { + let networkError = error as? URLError ?? URLError(.unknown) + + return .failure(.network(networkError)) + } + + guard let httpResponse = urlResponse as? HTTPURLResponse else { + return .failure(.network(URLError(.unknown))) + } + + let data = data ?? Data() + + // Treat all 2xx responses as success despite the subtle meaning they may convey + if (200..<300).contains(httpResponse.statusCode) { + return Self.decodeSuccessResponse(data) + } else { + return Self.decodeErrorResponse(data) + .flatMap { (serverErrorResponse) -> Result<Response.Output, RestError> in + return .failure(.server(serverErrorResponse)) + } + } + } + + /// A private helper that parses the JSON response in case of success (HTTP 200) + private static func decodeSuccessResponse(_ responseData: Data) -> Result<Response.Output, RestError> { + return Result { () -> Response.Output in + return try Response.decodeResponse(responseData) + }.mapError({ (error) -> RestError in + return .decodeSuccessResponse(error) + }) + } + + /// A private helper that parses the JSON response in case of error (Any HTTP code except 200) + private static func decodeErrorResponse(_ responseData: Data) -> Result<ServerErrorResponse, RestError> { + return Result { () -> ServerErrorResponse in + return try ServerErrorResponse.decodeResponse(responseData) + }.mapError({ (error) -> RestError in + return .decodeErrorResponse(error) + }) + } +} + +/// A convenience class for `RestEndpoint` that transparently provides it with the `URLSession` +struct RestSessionEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse { + let session: URLSession + let endpoint: RestEndpoint<Input, Response> + + init(session: URLSession, endpoint: RestEndpoint<Input, Response>) { + self.session = session + self.endpoint = endpoint + } + + /// Create `URLSessionDataTask` that automatically parses the HTTP response and returns the + /// expected response type or error upon completion. + func dataTask(payload: Input, completionHandler: @escaping (Result<Response.Output, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { + return endpoint.dataTask(session: session, payload: payload, completionHandler: completionHandler) + } + + /// Create `RestOperation` that automatically parses the response and sets the expected output + /// type or error upon completion. + func operation(payload: Input?) -> RestOperation<Input, Response> { + return endpoint.operation(session: session, payload: payload) + } +} + +// MARK: - REST interface + +struct MullvadRest { + let session: URLSession + + init(session: URLSession = URLSession(configuration: .ephemeral)) { + self.session = session + } + + func createAccount() -> RestSessionEndpoint<EmptyPayload, AccountResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.createAccount()) + } + + func getRelays() -> RestSessionEndpoint<EmptyPayload, ServerRelaysResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.getRelays()) + } + + func getAccountExpiry() -> RestSessionEndpoint<TokenPayload<EmptyPayload>, AccountResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.getAccountExpiry()) + } + + func pushWireguardKey() -> RestSessionEndpoint<TokenPayload<PushWireguardKeyRequest>, WireguardAddressesResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.pushWireguardKey()) + } + + func replaceWireguardKey() -> RestSessionEndpoint<TokenPayload<ReplaceWireguardKeyRequest>, WireguardAddressesResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.replaceWireguardKey()) + } + + func deleteWireguardKey() -> RestSessionEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.deleteWireguardKey()) + } + + func createApplePayment() -> RestSessionEndpoint<TokenPayload<CreateApplePaymentRequest>, CreateApplePaymentResponse> { + return RestSessionEndpoint(session: session, endpoint: Self.createApplePayment()) + } +} + +extension MullvadRest { + /// POST /v1/accounts + static func createAccount() -> RestEndpoint<EmptyPayload, AccountResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("accounts"), + httpMethod: .post + ) + } + + /// GET /v1/relays + static func getRelays() -> RestEndpoint<EmptyPayload, ServerRelaysResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("relays"), + httpMethod: .get + ) + } + + /// GET /v1/me + static func getAccountExpiry() -> RestEndpoint<TokenPayload<EmptyPayload>, AccountResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("me"), + httpMethod: .get + ) + } + + /// POST /v1/wireguard-keys + static func pushWireguardKey() -> RestEndpoint<TokenPayload<PushWireguardKeyRequest>, WireguardAddressesResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"), + httpMethod: .post + ) + } + + /// POST /v1/replace-wireguard-key + static func replaceWireguardKey() -> RestEndpoint<TokenPayload<ReplaceWireguardKeyRequest>, WireguardAddressesResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("replace-wireguard-key"), + httpMethod: .post + ) + } + + /// DELETE /v1/wireguard-keys/{pubkey} + static func deleteWireguardKey() -> RestEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"), + httpMethod: .delete + ) + } + + /// POST /v1/create-apple-payment + static func createApplePayment() -> RestEndpoint<TokenPayload<CreateApplePaymentRequest>, CreateApplePaymentResponse> { + return RestEndpoint( + endpointURL: kRestBaseURL.appendingPathComponent("create-apple-payment"), + httpMethod: .post + ) + } + + /// Returns a JSON encoder used by REST API + static func makeJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + return encoder + } + + /// Returns a JSON decoder used by REST API + static func makeJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return decoder + } +} + + +// MARK: - Payload types + +/// A payload that adds the authentication token into HTTP Authorization header +struct TokenPayload<Payload: RestPayload>: RestPayload { + let token: String + let payload: Payload + + init(token: String, payload: Payload) { + self.token = token + self.payload = payload + } + + func inject(into request: inout URLRequest) throws { + request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + try payload.inject(into: &request) + } +} + +/// A payload that adds the public key into the URL path +struct PublicKeyPayload<Payload: RestPayload>: RestPayload { + let pubKey: Data + let payload: Payload + + init(pubKey: Data, payload: Payload) { + self.pubKey = pubKey + self.payload = payload + } + + func inject(into request: inout URLRequest) throws { + request.url = request.url?.appendingPathComponent(pubKey.base64EncodedString()) + try payload.inject(into: &request) + } +} + +/// An empty payload placeholder type. +/// Use it in places where the payload is not expected +struct EmptyPayload: RestPayload { + init() {} + func inject(into request: inout URLRequest) throws {} +} + + +// MARK: - Response types + +struct AccountResponse: Decodable, RestResponse { + let token: String + let expires: Date +} + +struct ServerLocation: Decodable { + let country: String + let city: String + let latitude: Double + let longitude: Double +} + +struct ServerRelay: Decodable { + let hostname: String + let active: Bool + let owned: Bool + let location: String + let provider: String + let ipv4AddrIn: IPv4Address + let weight: Int32 + let includeInCountry: Bool +} + +struct ServerWireguardTunnel: Decodable { + let ipv4Gateway: IPv4Address + let ipv6Gateway: IPv6Address + let publicKey: Data + let portRanges: [ClosedRange<UInt16>] + let relays: [ServerRelay] +} + +struct ServerRelaysResponse: Decodable, RestResponse { + let locations: [String: ServerLocation] + let wireguard: [ServerWireguardTunnel] +} + +struct PushWireguardKeyRequest: Encodable, RestPayload { + let pubkey: Data +} + +struct WireguardAddressesResponse: Decodable, RestResponse { + let id: String + let pubkey: Data + let ipv4Address: IPAddressRange + let ipv6Address: IPAddressRange +} + +struct ReplaceWireguardKeyRequest: Encodable, RestPayload { + let old: Data + let new: Data +} + +struct CreateApplePaymentRequest: Encodable, RestPayload { + let receiptString: String +} + +struct CreateApplePaymentResponse: Decodable, RestResponse { + let timeAdded: Int + let newExpiry: Date +} |
