diff options
| -rw-r--r-- | ios/MullvadREST/AddressCache.swift | 115 | ||||
| -rw-r--r-- | ios/MullvadREST/ServerRelaysResponse.swift | 14 | ||||
| -rw-r--r-- | ios/MullvadRESTTests/AddressCacheTests.swift | 235 | ||||
| -rw-r--r-- | ios/MullvadTransport/ShadowsocksConfiguration.swift | 24 | ||||
| -rw-r--r-- | ios/MullvadTransport/ShadowsocksConfigurationCache.swift | 47 | ||||
| -rw-r--r-- | ios/MullvadTransport/TransportProvider.swift | 63 | ||||
| -rw-r--r-- | ios/MullvadTransport/URLSessionShadowsocksTransport.swift | 65 | ||||
| -rw-r--r-- | ios/MullvadTransport/URLSessionTransport.swift | 54 | ||||
| -rw-r--r-- | ios/MullvadTypes/FileCache.swift | 42 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 40 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppDelegate.swift | 15 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/AddressCacheTests.swift | 138 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/FileCacheTests.swift | 43 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/MockFileCache.swift | 63 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/RelayCacheTests.swift | 57 | ||||
| -rw-r--r-- | ios/PacketTunnel/PacketTunnelProvider.swift | 14 | ||||
| -rw-r--r-- | ios/RelayCache/CachedRelays.swift | 8 | ||||
| -rw-r--r-- | ios/RelayCache/RelayCache.swift | 81 |
18 files changed, 641 insertions, 477 deletions
diff --git a/ios/MullvadREST/AddressCache.swift b/ios/MullvadREST/AddressCache.swift index bc54f306b4..8279b1c263 100644 --- a/ios/MullvadREST/AddressCache.swift +++ b/ios/MullvadREST/AddressCache.swift @@ -15,11 +15,11 @@ extension REST { /// Logger. private let logger = Logger(label: "AddressCache") - /// Memory cache. - private var cachedAddresses: CachedAddresses = defaultCachedAddresses + /// Disk cache. + private let fileCache: any FileCacheProtocol<CachedAddresses> - /// Cache file location. - private let cacheFileURL: URL + /// Memory cache. + private var cache: CachedAddresses = defaultCachedAddresses /// Lock used for synchronizing access to instance members. private let cacheLock = NSLock() @@ -27,30 +27,26 @@ extension REST { /// Whether address cache can be written to. private let canWriteToCache: Bool - /// The name of the cache file on disk - internal static let cacheFileName = "api-ip-address.json" - /// The default set of endpoints to use as a fallback mechanism private static let defaultCachedAddresses = CachedAddresses( updatedAt: Date(timeIntervalSince1970: 0), endpoints: [REST.defaultAPIEndpoint] ) - // MARK: - - - // MARK: Public API + // MARK: - Public API /// Designated initializer. - public init(canWriteToCache: Bool, cacheFolder: URL) { - let cacheFileURL = cacheFolder.appendingPathComponent( - Self.cacheFileName, - isDirectory: false + public init(canWriteToCache: Bool, cacheDirectory: URL) { + fileCache = FileCache( + fileURL: cacheDirectory.appendingPathComponent("api-ip-address.json", isDirectory: false) ) - - self.cacheFileURL = cacheFileURL self.canWriteToCache = canWriteToCache + } - initCache() + /// Initializer that accepts a file cache implementation and can be used in tests. + init(canWriteToCache: Bool, fileCache: some FileCacheProtocol<CachedAddresses>) { + self.fileCache = fileCache + self.canWriteToCache = canWriteToCache } /// Returns the latest available endpoint @@ -60,18 +56,18 @@ extension REST { public func getCurrentEndpoint() -> AnyIPEndpoint { cacheLock.lock() defer { cacheLock.unlock() } - var currentEndpoint = cachedAddresses.endpoints.first ?? REST.defaultAPIEndpoint + var currentEndpoint = cache.endpoints.first ?? REST.defaultAPIEndpoint // Reload from disk cache when in the Network Extension as there is no `AddressCacheTracker` running // there if canWriteToCache == false { do { - cachedAddresses = try readFromCache() - if let firstEndpoint = cachedAddresses.endpoints.first { + cache = try fileCache.read() + if let firstEndpoint = cache.endpoints.first { currentEndpoint = firstEndpoint } } catch { - logger.error(error: error) + logger.error(error: error, message: "Failed to read address cache from disk.") } } return currentEndpoint @@ -92,24 +88,23 @@ extension REST { defer { cacheLock.unlock() } guard let firstEndpoint = endpoints.first else { return } - if Set(cachedAddresses.endpoints) == Set(endpoints) { - cachedAddresses.updatedAt = Date() + if Set(cache.endpoints) == Set(endpoints) { + cache.updatedAt = Date() } else { - cachedAddresses = CachedAddresses( + cache = CachedAddresses( updatedAt: Date(), endpoints: [firstEndpoint] ) } - if canWriteToCache { - do { - try writeToCache() - } catch { - logger.error( - error: error, - message: "Failed to write address cache after setting new endpoints." - ) - } + guard canWriteToCache else { return } + do { + try fileCache.write(cache) + } catch { + logger.error( + error: error, + message: "Failed to write address cache after setting new endpoints." + ) } } @@ -120,69 +115,29 @@ extension REST { cacheLock.lock() defer { cacheLock.unlock() } - return cachedAddresses.updatedAt + return cache.updatedAt } - // MARK: - Private API - - /// Initializes the cache by reading the a cached file from disk + /// Initializes the cache by reading the a cached file on disk. /// - /// If no cache file is present, a default API endpoint will be selected instead - private func initCache() { + /// If no cache file is present, a default API endpoint will be selected instead. + public func loadFromFile() { // The first time the application is ran, this statement will fail as there is no cache. This is fine. // The cache will be filled when either `getCurrentEndpoint` or `setEndpoints()` are called. do { - cachedAddresses = try readFromCache() + cache = try fileCache.read() } catch { logger.debug("Initialized cache with default API endpoint.") - cachedAddresses = Self.defaultCachedAddresses - } - } - - /// Reads the cache file from disk - /// - /// - Returns: A list of cached API endpoints in a `CachedAddresses` form - private func readFromCache() throws -> CachedAddresses { - let fileCoordinator = NSFileCoordinator(filePresenter: nil) - - let result = try fileCoordinator - .coordinate(readingItemAt: cacheFileURL, options: [.withoutChanges]) { file in - let data = try Data(contentsOf: file) - let cachedAddresses = try JSONDecoder().decode(CachedAddresses.self, from: data) - - if cachedAddresses.endpoints.isEmpty { - throw EmptyCacheError() - } - - return cachedAddresses - } - - return result - } - - /// Writes the cache file to the disk - private func writeToCache() throws { - precondition(canWriteToCache == true) - let fileCoordinator = NSFileCoordinator(filePresenter: nil) - - try fileCoordinator.coordinate(writingItemAt: cacheFileURL, options: [.forReplacing]) { file in - let data = try JSONEncoder().encode(self.cachedAddresses) - try data.write(to: file) + cache = Self.defaultCachedAddresses } } } - struct CachedAddresses: Codable { + public struct CachedAddresses: Codable, Equatable { /// Date when the cached addresses were last updated. var updatedAt: Date /// API endpoints. var endpoints: [AnyIPEndpoint] } - - struct EmptyCacheError: LocalizedError { - var errorDescription: String? { - return "Address cache file does not contain any API addresses." - } - } } diff --git a/ios/MullvadREST/ServerRelaysResponse.swift b/ios/MullvadREST/ServerRelaysResponse.swift index d91d05baee..081802196b 100644 --- a/ios/MullvadREST/ServerRelaysResponse.swift +++ b/ios/MullvadREST/ServerRelaysResponse.swift @@ -12,7 +12,7 @@ import struct Network.IPv4Address import struct Network.IPv6Address extension REST { - public struct ServerLocation: Codable { + public struct ServerLocation: Codable, Equatable { public let country: String public let city: String public let latitude: Double @@ -26,7 +26,7 @@ extension REST { } } - public struct BridgeRelay: Codable { + public struct BridgeRelay: Codable, Equatable { public let hostname: String public let active: Bool public let owned: Bool @@ -37,7 +37,7 @@ extension REST { public let includeInCountry: Bool } - public struct ServerRelay: Codable { + public struct ServerRelay: Codable, Equatable { public let hostname: String public let active: Bool public let owned: Bool @@ -74,7 +74,7 @@ extension REST { } } - public struct ServerWireguardTunnels: Codable { + public struct ServerWireguardTunnels: Codable, Equatable { public let ipv4Gateway: IPv4Address public let ipv6Gateway: IPv6Address public let portRanges: [[UInt16]] @@ -93,7 +93,7 @@ extension REST { } } - public struct ServerShadowsocks: Codable { + public struct ServerShadowsocks: Codable, Equatable { public let `protocol`: String public let port: UInt16 public let cipher: String @@ -107,7 +107,7 @@ extension REST { } } - public struct ServerBridges: Codable { + public struct ServerBridges: Codable, Equatable { public let shadowsocks: [ServerShadowsocks] public let relays: [BridgeRelay] @@ -117,7 +117,7 @@ extension REST { } } - public struct ServerRelaysResponse: Codable { + public struct ServerRelaysResponse: Codable, Equatable { public let locations: [String: ServerLocation] public let wireguard: ServerWireguardTunnels public let bridge: ServerBridges diff --git a/ios/MullvadRESTTests/AddressCacheTests.swift b/ios/MullvadRESTTests/AddressCacheTests.swift deleted file mode 100644 index 85387adee7..0000000000 --- a/ios/MullvadRESTTests/AddressCacheTests.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// AddressCacheTests.swift -// MullvadRESTTests -// -// Created by Marco Nikic on 2023-05-05. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -@testable import MullvadREST -import MullvadTypes -import XCTest - -final class AddressCacheTests: XCTestCase { - static var testsCacheDirectory: URL! - var apiEndpoint: AnyIPEndpoint! - var cacheFilePresenter: AddressCacheFilePresenter! - let defaultExpectationTimeout = REST.Duration.milliseconds(200).timeInterval - - // MARK: Tests Setup - - override class func setUp() { - super.setUp() - let temporaryDirectory = FileManager.default.temporaryDirectory - testsCacheDirectory = temporaryDirectory.appendingPathComponent("AddressCacheTests") - } - - override func setUpWithError() throws { - try super.setUpWithError() - apiEndpoint = try XCTUnwrap(AnyIPEndpoint(string: "127.0.0.1:80")) - let cacheFileURL = Self.testsCacheDirectory.appendingPathComponent(REST.AddressCache.cacheFileName) - cacheFilePresenter = AddressCacheFilePresenter(presentedItemURL: cacheFileURL) - NSFileCoordinator.addFilePresenter(cacheFilePresenter) - } - - override func tearDownWithError() throws { - NSFileCoordinator.removeFilePresenter(cacheFilePresenter) - try super.tearDownWithError() - } - - // MARK: - - - // MARK: Tests - - func testAddressCacheHasDefaultEndpoint() { - let cache = REST.AddressCache(canWriteToCache: false, cacheFolder: Self.testsCacheDirectory) - XCTAssertEqual(cache.getCurrentEndpoint(), REST.defaultAPIEndpoint) - } - - func testSetEndpoints() throws { - let cache = REST.AddressCache(canWriteToCache: false, cacheFolder: Self.testsCacheDirectory) - - cache.setEndpoints([apiEndpoint]) - XCTAssertEqual(cache.getCurrentEndpoint(), apiEndpoint) - } - - func testSetEndpointsUpdatesDateWhenSettingSameAddress() throws { - let cache = REST.AddressCache(canWriteToCache: false, cacheFolder: Self.testsCacheDirectory) - cache.setEndpoints([apiEndpoint]) - - let dateBeforeSettingEndpoint = Date() - cache.setEndpoints([apiEndpoint]) - let dateAfterSettingEndpoint = Date() - - let dateIntervalRange = dateBeforeSettingEndpoint ... dateAfterSettingEndpoint - XCTAssertTrue(dateIntervalRange.contains(cache.getLastUpdateDate())) - } - - func testSetEndpointsDoesNotDoAnythingIfSettingEmptyEndpoints() throws { - let didNotWriteToCache = expectation(description: "Did not write to cache") - didNotWriteToCache.isInverted = true - - cacheFilePresenter.onWriterAction = { - didNotWriteToCache.fulfill() - } - - try withCachefolders { cacheDirectory, _ in - let cache = REST.AddressCache(canWriteToCache: true, cacheFolder: cacheDirectory) - cache.setEndpoints([]) - } - - waitForExpectations(timeout: defaultExpectationTimeout) - } - - func testSetEndpointsOnlyAcceptsTheFirstEndpoint() throws { - let ipAddresses = (1 ... 10) - .map { "\($0).\($0).\($0).\($0):80" } - .compactMap { AnyIPEndpoint(string: $0) } - - let firstIPEndpoint = try XCTUnwrap(ipAddresses.first) - - try withCachefolders { cacheDirectory, cacheFileURL in - let cache = REST.AddressCache(canWriteToCache: true, cacheFolder: cacheDirectory) - cache.setEndpoints(ipAddresses) - - let cachedContent = try Data(contentsOf: cacheFileURL) - let cachedAddresses = try JSONDecoder().decode(REST.CachedAddresses.self, from: cachedContent) - - XCTAssertEqual(cachedAddresses.endpoints.count, 1) - XCTAssertEqual(cache.getCurrentEndpoint(), firstIPEndpoint) - } - } - - func testCacheReadsFromCachedFileAtInit() throws { - let didReadFromCache = expectation(description: "Cache was read") - cacheFilePresenter.onReaderAction = { - didReadFromCache.fulfill() - } - - try withCachefolders { cacheDirectory, cacheFileURL in - let fixedDate = Date() - try prepopulateCache(at: cacheFileURL, fixedDate: fixedDate, with: [apiEndpoint]) - let cache = REST.AddressCache(canWriteToCache: true, cacheFolder: cacheDirectory) - - XCTAssertEqual(cache.getCurrentEndpoint(), apiEndpoint) - XCTAssertEqual(cache.getLastUpdateDate(), fixedDate) - } - - waitForExpectations(timeout: defaultExpectationTimeout) - } - - func testCacheWritesToDiskWhenSettingNewEndpoints() throws { - let didWriteToCache = expectation(description: "Cache was written to") - cacheFilePresenter.onWriterAction = { - didWriteToCache.fulfill() - } - - try withCachefolders { cacheDirectory, cacheFileURL in - - let cache = REST.AddressCache(canWriteToCache: true, cacheFolder: cacheDirectory) - cache.setEndpoints([apiEndpoint]) - let cachedContent = try Data(contentsOf: cacheFileURL) - let cachedAddresses = try JSONDecoder().decode(REST.CachedAddresses.self, from: cachedContent) - let cachedAddress = try XCTUnwrap(cachedAddresses.endpoints.first) - - XCTAssertEqual(cachedAddress, cache.getCurrentEndpoint()) - XCTAssertEqual(cachedAddresses.updatedAt, cache.getLastUpdateDate()) - } - - waitForExpectations(timeout: defaultExpectationTimeout) - } - - func testGetCurrentEndpointReadsFromCacheWhenReadOnly() throws { - let didReadFromCache = expectation(description: "Cache was read") - // Cache will be read from twice. Once during init, once when getting current endpoint - didReadFromCache.expectedFulfillmentCount = 2 - cacheFilePresenter.onReaderAction = { - didReadFromCache.fulfill() - } - - try withCachefolders { cacheDirectory, cacheFileURL in - let cache = REST.AddressCache(canWriteToCache: false, cacheFolder: cacheDirectory) - try prepopulateCache(at: cacheFileURL, with: [apiEndpoint]) - - XCTAssertEqual(cache.getCurrentEndpoint(), apiEndpoint) - } - - waitForExpectations(timeout: defaultExpectationTimeout) - } - - func testGetCurrentEndpointHasDefaultEndpointIfCacheIsEmpty() throws { - let didReadFromCache = expectation(description: "Cache was read") - // Cache will be read from twice. Once during init, once when getting current endpoint - didReadFromCache.expectedFulfillmentCount = 2 - cacheFilePresenter.onReaderAction = { - didReadFromCache.fulfill() - } - - try withCachefolders { cacheDirectory, cacheFileURL in - try prepopulateCache(at: cacheFileURL, with: []) - - let cache = REST.AddressCache(canWriteToCache: false, cacheFolder: cacheDirectory) - XCTAssertEqual(cache.getCurrentEndpoint(), REST.defaultAPIEndpoint) - } - - waitForExpectations(timeout: defaultExpectationTimeout) - } -} - -// MARK: - - -extension AddressCacheTests { - /// Prepares a cache folder that is expected to be present during the `runTest` closure - /// - Parameter runTest: A closure that expects a `cacheDirectory` encapsulating `cacheFileURL` to be present when - /// it runs - func withCachefolders(_ runTest: (_ cacheDirectory: URL, _ cacheFileURL: URL) throws -> Void) throws { - let cacheFileURL = try XCTUnwrap(cacheFilePresenter.presentedItemURL) - let fileManager = FileManager.default - let cacheDirectory = try XCTUnwrap(Self.testsCacheDirectory) - try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) - - try runTest(cacheDirectory, cacheFileURL) - - try fileManager.removeItem(at: cacheDirectory) - } - - /// Populates a JSON cache file containing a `Date` and `[AnyIPEndpoint]` - /// - /// - Parameters: - /// - cacheFileURL: The cache file destination - /// - fixedDate: The `Date` the cache file was written to - /// - endpoints: A list of `AnyIPEndpoint` to write in the cache - func prepopulateCache(at cacheFileURL: URL, fixedDate: Date = Date(), with endpoints: [AnyIPEndpoint]) throws { - let prepopulatedCache = REST.CachedAddresses(updatedAt: fixedDate, endpoints: endpoints) - let encodedCache = try JSONEncoder().encode(prepopulatedCache) - try encodedCache.write(to: cacheFileURL) - } -} - -class AddressCacheFilePresenter: NSObject, NSFilePresenter { - var presentedItemURL: URL? - let operationQueue: OperationQueue - let dispatchQueue = DispatchQueue(label: "com.MullvadVPN.AddressCacheTests") - var presentedItemOperationQueue: OperationQueue { operationQueue } - - var onReaderAction: (() -> Void)? - var onWriterAction: (() -> Void)? - - init(presentedItemURL: URL) { - operationQueue = OperationQueue() - self.presentedItemURL = presentedItemURL - operationQueue.underlyingQueue = dispatchQueue - } - - func relinquishPresentedItem(toReader reader: @escaping ((() -> Void)?) -> Void) { - print(#function) - onReaderAction?() - reader(nil) - } - - func relinquishPresentedItem(toWriter writer: @escaping ((() -> Void)?) -> Void) { - print(#function) - onWriterAction?() - writer(nil) - } -} diff --git a/ios/MullvadTransport/ShadowsocksConfiguration.swift b/ios/MullvadTransport/ShadowsocksConfiguration.swift new file mode 100644 index 0000000000..0a145f0976 --- /dev/null +++ b/ios/MullvadTransport/ShadowsocksConfiguration.swift @@ -0,0 +1,24 @@ +// +// ShadowsocksConfiguration.swift +// MullvadTransport +// +// Created by Marco Nikic on 2023-06-05. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Network + +public struct ShadowsocksConfiguration: Codable { + public let bridgeAddress: IPv4Address + public let bridgePort: UInt16 + public let password: String + public let cipher: String + + public init(bridgeAddress: IPv4Address, bridgePort: UInt16, password: String, cipher: String) { + self.bridgeAddress = bridgeAddress + self.bridgePort = bridgePort + self.password = password + self.cipher = cipher + } +} diff --git a/ios/MullvadTransport/ShadowsocksConfigurationCache.swift b/ios/MullvadTransport/ShadowsocksConfigurationCache.swift new file mode 100644 index 0000000000..111215138e --- /dev/null +++ b/ios/MullvadTransport/ShadowsocksConfigurationCache.swift @@ -0,0 +1,47 @@ +// +// ShadowsocksConfigurationCache.swift +// MullvadTransport +// +// Created by Marco Nikic on 2023-06-05. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// Holds a shadowsocks configuration object backed by a caching mechanism shared across processes +public final class ShadowsocksConfigurationCache { + private let configurationLock = NSLock() + private var cachedConfiguration: ShadowsocksConfiguration? + private let fileCache: FileCache<ShadowsocksConfiguration> + + public init(cacheDirectory: URL) { + fileCache = FileCache( + fileURL: cacheDirectory.appendingPathComponent("shadowsocks-cache.json", isDirectory: false) + ) + } + + /// Returns configration from memory cache if available, otherwise attempts to load it from disk cache before + /// returning. + public func read() throws -> ShadowsocksConfiguration { + configurationLock.lock() + defer { configurationLock.unlock() } + + if let cachedConfiguration { + return cachedConfiguration + } else { + let readConfiguration = try fileCache.read() + cachedConfiguration = readConfiguration + return readConfiguration + } + } + + /// Replace memory cache with new configuration and attempt to persist it on disk. + public func write(_ configuration: ShadowsocksConfiguration) throws { + configurationLock.lock() + defer { configurationLock.unlock() } + + cachedConfiguration = configuration + try fileCache.write(configuration) + } +} diff --git a/ios/MullvadTransport/TransportProvider.swift b/ios/MullvadTransport/TransportProvider.swift index 821ea3d059..786f34a2b2 100644 --- a/ios/MullvadTransport/TransportProvider.swift +++ b/ios/MullvadTransport/TransportProvider.swift @@ -18,11 +18,18 @@ public final class TransportProvider: RESTTransportProvider { private let relayCache: RelayCache private let logger = Logger(label: "TransportProvider") private let addressCache: REST.AddressCache + private let shadowsocksCache: ShadowsocksConfigurationCache - public init(urlSessionTransport: URLSessionTransport, relayCache: RelayCache, addressCache: REST.AddressCache) { + public init( + urlSessionTransport: URLSessionTransport, + relayCache: RelayCache, + addressCache: REST.AddressCache, + shadowsocksCache: ShadowsocksConfigurationCache + ) { self.urlSessionTransport = urlSessionTransport self.relayCache = relayCache self.addressCache = addressCache + self.shadowsocksCache = shadowsocksCache } public func transport() -> RESTTransport? { @@ -31,29 +38,57 @@ public final class TransportProvider: RESTTransportProvider { public func shadowsocksTransport() -> RESTTransport? { do { - let cachedRelays = try relayCache.read() - let shadowsocksConfiguration = RelaySelector.getShadowsocksTCPBridge(relays: cachedRelays.relays) - let shadowsocksBridgeRelay = RelaySelector.getShadowsocksRelay(relays: cachedRelays.relays) - - guard let shadowsocksConfiguration, - let shadowsocksBridgeRelay - else { - logger.error("Could not get shadow socks bridge information.") - return nil - } + let shadowsocksConfiguration = try shadowsocksConfiguration() let shadowsocksURLSession = urlSessionTransport.urlSession let shadowsocksTransport = URLSessionShadowsocksTransport( urlSession: shadowsocksURLSession, shadowsocksConfiguration: shadowsocksConfiguration, - shadowsocksBridgeRelay: shadowsocksBridgeRelay, addressCache: addressCache ) return shadowsocksTransport } catch { - logger.error(error: error) + logger.error(error: error, message: "Failed to produce shadowsocks configuration.") + return nil + } + } + + /// Returns the last used shadowsocks configuration, otherwise a new randomized configuration. + private func shadowsocksConfiguration() throws -> ShadowsocksConfiguration { + // If a previous shadowsocks configuration was in cache, return it directly. + // There is no previous configuration either if this is the first time this code ran + // Or because the previous shadowsocks configuration was invalid, therefore generate a new one. + do { + return try shadowsocksCache.read() + } catch { + // There is no previous configuration either if this is the first time this code ran + // Or because the previous shadowsocks configuration was invalid, therefore generate a new one. + return try makeNewShadowsocksConfiguration() } - return nil + } + + /// Returns a randomly selected shadowsocks configuration. + private func makeNewShadowsocksConfiguration() throws -> ShadowsocksConfiguration { + let cachedRelays = try relayCache.read() + let bridgeAddress = RelaySelector.getShadowsocksRelay(relays: cachedRelays.relays)?.ipv4AddrIn + let bridgeConfiguration = RelaySelector.getShadowsocksTCPBridge(relays: cachedRelays.relays) + + guard let bridgeAddress, let bridgeConfiguration else { throw POSIXError(.ENOENT) } + + let newConfiguration = ShadowsocksConfiguration( + bridgeAddress: bridgeAddress, + bridgePort: bridgeConfiguration.port, + password: bridgeConfiguration.password, + cipher: bridgeConfiguration.cipher + ) + + do { + try shadowsocksCache.write(newConfiguration) + } catch { + logger.error(error: error, message: "Failed to persist shadowsocks cache.") + } + + return newConfiguration } } diff --git a/ios/MullvadTransport/URLSessionShadowsocksTransport.swift b/ios/MullvadTransport/URLSessionShadowsocksTransport.swift new file mode 100644 index 0000000000..a4e7e34403 --- /dev/null +++ b/ios/MullvadTransport/URLSessionShadowsocksTransport.swift @@ -0,0 +1,65 @@ +// +// ShadowsocksTransport.swift +// MullvadTransport +// +// Created by pronebird on 12/06/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes + +public final class URLSessionShadowsocksTransport: RESTTransport { + /// The Shadowsocks proxy instance that proxies all the traffic it receives + private let shadowsocksProxy: ShadowsocksProxy + + /// The IPv4 representation of the loopback address used by `shadowsocksProxy` + private let localhost = "127.0.0.1" + + /// The `URLSession` used to send requests via `shadowsocksProxy` + public let urlSession: URLSession + + public var name: String { + return "shadow-socks-url-session" + } + + public init( + urlSession: URLSession, + shadowsocksConfiguration: ShadowsocksConfiguration, + addressCache: REST.AddressCache + ) { + self.urlSession = urlSession + let apiAddress = addressCache.getCurrentEndpoint() + + shadowsocksProxy = ShadowsocksProxy( + forwardAddress: apiAddress.ip, + forwardPort: apiAddress.port, + bridgeAddress: shadowsocksConfiguration.bridgeAddress, + bridgePort: shadowsocksConfiguration.bridgePort, + password: shadowsocksConfiguration.password, + cipher: shadowsocksConfiguration.cipher + ) + } + + public func sendRequest( + _ request: URLRequest, + completion: @escaping (Data?, URLResponse?, Swift.Error?) -> Void + ) -> Cancellable { + // Start the Shadowsocks proxy in order to get a local port + shadowsocksProxy.start() + + // Copy the URL request and rewrite the host and port to point to the Shadowsocks proxy instance + var urlRequestCopy = request + urlRequestCopy.url = request.url.flatMap { url in + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = localhost + components?.port = Int(shadowsocksProxy.localPort()) + return components?.url + } + + let dataTask = urlSession.dataTask(with: urlRequestCopy, completionHandler: completion) + dataTask.resume() + return dataTask + } +} diff --git a/ios/MullvadTransport/URLSessionTransport.swift b/ios/MullvadTransport/URLSessionTransport.swift index 49bc131895..4ecef05870 100644 --- a/ios/MullvadTransport/URLSessionTransport.swift +++ b/ios/MullvadTransport/URLSessionTransport.swift @@ -31,57 +31,3 @@ public final class URLSessionTransport: RESTTransport { return dataTask } } - -public final class URLSessionShadowsocksTransport: RESTTransport { - /// The Shadowsocks proxy instance that proxies all the traffic it receives - private let shadowsocksProxy: ShadowsocksProxy - /// The IPv4 representation of the loopback address used by `shadowsocksProxy` - private let localhost = "127.0.0.1" - - /// The `URLSession` used to send requests via `shadowsocksProxy` - public let urlSession: URLSession - - public var name: String { - return "shadow-socks-url-session" - } - - public init( - urlSession: URLSession, - shadowsocksConfiguration: REST.ServerShadowsocks, - shadowsocksBridgeRelay: REST.BridgeRelay, - addressCache: REST.AddressCache - ) { - self.urlSession = urlSession - let apiAddress = addressCache.getCurrentEndpoint() - - shadowsocksProxy = ShadowsocksProxy( - forwardAddress: apiAddress.ip, - forwardPort: apiAddress.port, - bridgeAddress: shadowsocksBridgeRelay.ipv4AddrIn, - bridgePort: shadowsocksConfiguration.port, - password: shadowsocksConfiguration.password, - cipher: shadowsocksConfiguration.cipher - ) - } - - public func sendRequest( - _ request: URLRequest, - completion: @escaping (Data?, URLResponse?, Swift.Error?) -> Void - ) -> Cancellable { - // Start the Shadowsocks proxy in order to get a local port - shadowsocksProxy.start() - - // Copy the URL request and rewrite the host and port to point to the Shadowsocks proxy instance - var urlRequestCopy = request - urlRequestCopy.url = request.url.flatMap { url in - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = localhost - components?.port = Int(shadowsocksProxy.localPort()) - return components?.url - } - - let dataTask = urlSession.dataTask(with: urlRequestCopy, completionHandler: completion) - dataTask.resume() - return dataTask - } -} diff --git a/ios/MullvadTypes/FileCache.swift b/ios/MullvadTypes/FileCache.swift new file mode 100644 index 0000000000..4e16a73c41 --- /dev/null +++ b/ios/MullvadTypes/FileCache.swift @@ -0,0 +1,42 @@ +// +// FileCache.swift +// MullvadTypes +// +// Created by Marco Nikic on 2023-05-30. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// File cache implementation that can read and write any `Codable` content and uses file coordinator to coordinate I/O. +public struct FileCache<Content: Codable>: FileCacheProtocol { + public let fileURL: URL + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public func read() throws -> Content { + let fileCoordinator = NSFileCoordinator(filePresenter: nil) + + return try fileCoordinator.coordinate(readingItemAt: fileURL, options: [.withoutChanges]) { fileURL in + return try JSONDecoder().decode(Content.self, from: Data(contentsOf: fileURL)) + } + } + + public func write(_ content: Content) throws { + let fileCoordinator = NSFileCoordinator(filePresenter: nil) + + try fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forReplacing]) { fileURL in + try JSONEncoder().encode(content).write(to: fileURL) + } + } +} + +/// Protocol describing file cache that's able to read and write serializable content. +public protocol FileCacheProtocol<Content> { + associatedtype Content: Codable + + func read() throws -> Content + func write(_ content: Content) throws +} diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 03d65f838e..f16f22ba27 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -79,6 +79,8 @@ 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; }; 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */; }; 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */; }; + 5822C0042A3724A800A3A5FB /* ShadowsocksConfigurationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9467E8A2A2E0317000DC21F /* ShadowsocksConfigurationCache.swift */; }; + 5822C0052A3724A800A3A5FB /* ShadowsocksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9467E872A2DCD57000DC21F /* ShadowsocksConfiguration.swift */; }; 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5823FA5326CE49F600283BF8 /* TunnelObserver.swift */; }; 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */; }; 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB025124117005D0BB5 /* CustomTextField.swift */; }; @@ -251,6 +253,8 @@ 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */; }; 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3F4F82964B08300D72515 /* MapViewController.swift */; }; 58C3F4FB296C3AD500D72515 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */; }; + 58C3FA662A38549D006A450A /* MockFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3FA652A38549D006A450A /* MockFileCache.swift */; }; + 58C3FA682A385C89006A450A /* FileCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3FA672A385C89006A450A /* FileCacheTests.swift */; }; 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A072A33850E00100D75 /* ApplicationTarget.swift */; }; 58C76A092A33850E00100D75 /* ApplicationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A072A33850E00100D75 /* ApplicationTarget.swift */; }; 58C76A0B2A338E4300100D75 /* BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A0A2A338E4300100D75 /* BackgroundTask.swift */; }; @@ -340,6 +344,7 @@ 58E0729F28814ACC008902F8 /* WireGuardLogLevel+Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E0729E28814ACC008902F8 /* WireGuardLogLevel+Logging.swift */; }; 58E072A128814B0E008902F8 /* MullvadEndpoint+WgEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E072A028814B0E008902F8 /* MullvadEndpoint+WgEndpoint.swift */; }; 58E0A98827C8F46300FE6BDD /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E0A98727C8F46300FE6BDD /* Tunnel.swift */; }; + 58E0E2842A3718CE002E3420 /* URLSessionShadowsocksTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E0E2832A3718CE002E3420 /* URLSessionShadowsocksTransport.swift */; }; 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E11187292FA11F009FCA84 /* SettingsMigrationUIHandler.swift */; }; 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E20770274672CA00DE5D77 /* LaunchViewController.swift */; }; 58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E25F802837BBBB002CFB2C /* SceneDelegate.swift */; }; @@ -386,6 +391,8 @@ A917351F29FAA9C400D5DCFD /* RESTTransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A917351E29FAA9C400D5DCFD /* RESTTransportStrategy.swift */; }; A917352129FAAA5200D5DCFD /* TransportStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */; }; A93D13782A1F60A6001EB0B1 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 586F2BE129F6916F009E6924 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; }; + A9467E7F2A29DEFE000DC21F /* RelayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */; }; + A9467E802A29E0A6000DC21F /* AddressCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */; }; A95F86B72A1F53BA00245DAC /* URLSessionTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE67C28F83CA50033DD93 /* URLSessionTransport.swift */; }; A95F86B82A1F547000245DAC /* ShadowsocksProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F1FF1B29F06124007083C3 /* ShadowsocksProxy.swift */; }; A97F1F442A1F4E1A00ECEFDE /* MullvadTransport.h in Headers */ = {isa = PBXBuildFile; fileRef = A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -393,8 +400,8 @@ A97F1F482A1F4E1A00ECEFDE /* MullvadTransport.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A97FF54B2A0B7AD000900996 /* SimulatorTunnelTransportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97FF54A2A0B7AD000900996 /* SimulatorTunnelTransportProvider.swift */; }; A97FF5502A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97FF54F2A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift */; }; + A9A8A8EB2A262AB30086D569 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A8A8EA2A262AB30086D569 /* FileCache.swift */; }; A9B2CF722A1F64CD0013CC6C /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; - A9CF11FD2A0518E7001D9565 /* AddressCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */; }; A9D99B9A2A1F7C3200DE27D3 /* RESTTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE67D28F83CA50033DD93 /* RESTTransport.swift */; }; A9D99BA02A1F7F3A00DE27D3 /* TransportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D99B9F2A1F7F3A00DE27D3 /* TransportProvider.swift */; }; A9D99BA52A1F808900DE27D3 /* RelayCache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 063F02732902B63F001FA09F /* RelayCache.framework */; }; @@ -1020,6 +1027,8 @@ 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInputGroupView.swift; sourceTree = "<group>"; }; 58C3F4F82964B08300D72515 /* MapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewController.swift; sourceTree = "<group>"; }; 58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = "<group>"; }; + 58C3FA652A38549D006A450A /* MockFileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFileCache.swift; sourceTree = "<group>"; }; + 58C3FA672A385C89006A450A /* FileCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCacheTests.swift; sourceTree = "<group>"; }; 58C76A072A33850E00100D75 /* ApplicationTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationTarget.swift; sourceTree = "<group>"; }; 58C76A0A2A338E4300100D75 /* BackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTask.swift; sourceTree = "<group>"; }; 58C774BD29A7A249003A1A56 /* CustomNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationController.swift; sourceTree = "<group>"; }; @@ -1062,6 +1071,7 @@ 58E072A028814B0E008902F8 /* MullvadEndpoint+WgEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MullvadEndpoint+WgEndpoint.swift"; sourceTree = "<group>"; }; 58E072A428814C28008902F8 /* TunnelMonitorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorDelegate.swift; sourceTree = "<group>"; }; 58E0A98727C8F46300FE6BDD /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = "<group>"; }; + 58E0E2832A3718CE002E3420 /* URLSessionShadowsocksTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionShadowsocksTransport.swift; sourceTree = "<group>"; }; 58E11187292FA11F009FCA84 /* SettingsMigrationUIHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationUIHandler.swift; sourceTree = "<group>"; }; 58E20770274672CA00DE5D77 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = "<group>"; }; 58E25F802837BBBB002CFB2C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; @@ -1109,10 +1119,14 @@ 7AF0419D29E957EB00D492DD /* AccountCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCoordinator.swift; sourceTree = "<group>"; }; A917351E29FAA9C400D5DCFD /* RESTTransportStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTransportStrategy.swift; sourceTree = "<group>"; }; A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportStrategyTests.swift; sourceTree = "<group>"; }; + A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = "<group>"; }; + A9467E872A2DCD57000DC21F /* ShadowsocksConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksConfiguration.swift; sourceTree = "<group>"; }; + A9467E8A2A2E0317000DC21F /* ShadowsocksConfigurationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksConfigurationCache.swift; sourceTree = "<group>"; }; A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MullvadTransport.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadTransport.h; sourceTree = "<group>"; }; A97FF54A2A0B7AD000900996 /* SimulatorTunnelTransportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelTransportProvider.swift; sourceTree = "<group>"; }; A97FF54F2A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSFileCoordinator+Extensions.swift"; sourceTree = "<group>"; }; + A9A8A8EA2A262AB30086D569 /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = "<group>"; }; A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCacheTests.swift; sourceTree = "<group>"; }; A9D99B9F2A1F7F3A00DE27D3 /* TransportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportProvider.swift; sourceTree = "<group>"; }; E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeViewController.swift; sourceTree = "<group>"; }; @@ -1359,6 +1373,7 @@ 584D26BE270C550B004EA533 /* AnyIPAddress.swift */, 586A951329013235007BAF2B /* AnyIPEndpoint.swift */, 06AC113628F83FD70037AF9A /* Cancellable.swift */, + A9A8A8EA2A262AB30086D569 /* FileCache.swift */, 58E511E328DDDE8900B0BCDE /* CustomErrorDescriptionProtocol.swift */, 586168682976F6BD00EF8598 /* DisplayError.swift */, 58E511EA28DDE18400B0BCDE /* Error+Chain.swift */, @@ -1863,14 +1878,18 @@ 58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = { isa = PBXGroup; children = ( + 58B0A2A4238EE67E00BC001D /* Info.plist */, + F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */, 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */, 58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */, + 58C3FA672A385C89006A450A /* FileCacheTests.swift */, 582A8A3928BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift */, - 58B0A2A4238EE67E00BC001D /* Info.plist */, - F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */, 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, 5807E2C1243203D000F5FF30 /* StringTests.swift */, 58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */, + 58C3FA652A38549D006A450A /* MockFileCache.swift */, ); path = MullvadVPNTests; sourceTree = "<group>"; @@ -2120,7 +2139,6 @@ 58FBFBE7291622580020E046 /* MullvadRESTTests */ = { isa = PBXGroup; children = ( - A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */, 58FBFBF0291630700020E046 /* DurationTests.swift */, 58FBFBE8291622580020E046 /* ExponentialBackoffTests.swift */, A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */, @@ -2131,11 +2149,14 @@ A97F1F422A1F4E1A00ECEFDE /* MullvadTransport */ = { isa = PBXGroup; children = ( + A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */, 586F2BE129F6916F009E6924 /* shadowsocks.h */, 06FAE67C28F83CA50033DD93 /* URLSessionTransport.swift */, + 58E0E2832A3718CE002E3420 /* URLSessionShadowsocksTransport.swift */, 01F1FF1B29F06124007083C3 /* ShadowsocksProxy.swift */, - A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */, A9D99B9F2A1F7F3A00DE27D3 /* TransportProvider.swift */, + A9467E872A2DCD57000DC21F /* ShadowsocksConfiguration.swift */, + A9467E8A2A2E0317000DC21F /* ShadowsocksConfigurationCache.swift */, ); path = MullvadTransport; sourceTree = "<group>"; @@ -2851,19 +2872,23 @@ 58B8644529C7971B005E107C /* InputTextFormatter.swift in Sources */, 58915D692A2601FB0066445B /* WgKeyRotation.swift in Sources */, 580810E62A30E13D00B74552 /* DeviceStateAccessorProtocol.swift in Sources */, + 58C3FA662A38549D006A450A /* MockFileCache.swift in Sources */, 58915D642A25F8B30066445B /* DeviceCheckOperation.swift in Sources */, 58915D652A25F9E20066445B /* TunnelSettingsV2.swift in Sources */, 58B8644529C7971B005E107C /* InputTextFormatter.swift in Sources */, + A9467E7F2A29DEFE000DC21F /* RelayCacheTests.swift in Sources */, 582A8A3A28BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift in Sources */, 58915D632A25F8400066445B /* DeviceCheckOperationTests.swift in Sources */, 5896AE86246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift in Sources */, 58B8644629C7972F005E107C /* CustomDateComponentsFormatting.swift in Sources */, 5807E2C2243203D000F5FF30 /* StringTests.swift in Sources */, + 58C3FA682A385C89006A450A /* FileCacheTests.swift in Sources */, 58165EBE2A262CBB00688EAD /* WgKeyRotationTests.swift in Sources */, 5807E2C3243203E700F5FF30 /* String+Split.swift in Sources */, 580810E92A30E17300B74552 /* DeviceCheckRemoteServiceProtocol.swift in Sources */, F07BF2582A26112D00042943 /* InputTextFormatterTests.swift in Sources */, 58B0A2A8238EE68200BC001D /* RelaySelectorTests.swift in Sources */, + A9467E802A29E0A6000DC21F /* AddressCacheTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3156,6 +3181,7 @@ 58E45A5729F12C5100281ECF /* Result+Extensions.swift in Sources */, 58D2240B294C90210029F5F8 /* Cancellable.swift in Sources */, 58D2240C294C90210029F5F8 /* WrappingError.swift in Sources */, + A9A8A8EB2A262AB30086D569 /* FileCache.swift in Sources */, 58D2240D294C90210029F5F8 /* CustomErrorDescriptionProtocol.swift in Sources */, 58D2240E294C90210029F5F8 /* Error+Chain.swift in Sources */, 586168692976F6BD00EF8598 /* DisplayError.swift in Sources */, @@ -3192,7 +3218,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A9CF11FD2A0518E7001D9565 /* AddressCacheTests.swift in Sources */, A917352129FAAA5200D5DCFD /* TransportStrategyTests.swift in Sources */, 58FBFBE9291622580020E046 /* ExponentialBackoffTests.swift in Sources */, 58FBFBF1291630700020E046 /* DurationTests.swift in Sources */, @@ -3203,9 +3228,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5822C0052A3724A800A3A5FB /* ShadowsocksConfiguration.swift in Sources */, A95F86B82A1F547000245DAC /* ShadowsocksProxy.swift in Sources */, A95F86B72A1F53BA00245DAC /* URLSessionTransport.swift in Sources */, + 5822C0042A3724A800A3A5FB /* ShadowsocksConfigurationCache.swift in Sources */, A9D99BA02A1F7F3A00DE27D3 /* TransportProvider.swift in Sources */, + 58E0E2842A3718CE002E3420 /* URLSessionShadowsocksTransport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 0272c27b8a..0906f376ed 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -10,6 +10,7 @@ import BackgroundTasks import MullvadLogging import MullvadREST import MullvadTransport +import MullvadTypes import Operations import RelayCache import StoreKit @@ -50,7 +51,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD logger = Logger(label: "AppDelegate") - addressCache = REST.AddressCache(canWriteToCache: true, cacheFolder: ApplicationConfiguration.containerURL) + let containerURL = ApplicationConfiguration.containerURL + + addressCache = REST.AddressCache(canWriteToCache: true, cacheDirectory: containerURL) + addressCache.loadFromFile() proxyFactory = REST.ProxyFactory.makeProxyFactory( transportProvider: { [weak self] in self?.transportMonitor }, @@ -61,10 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD accountsProxy = proxyFactory.createAccountsProxy() devicesProxy = proxyFactory.createDevicesProxy() - let relayCache = RelayCache( - securityGroupIdentifier: ApplicationConfiguration.securityGroupIdentifier - )! - + let relayCache = RelayCache(cacheDirectory: containerURL) relayCacheTracker = RelayCacheTracker(relayCache: relayCache, application: application, apiProxy: apiProxy) addressCacheTracker = AddressCacheTracker( @@ -91,10 +92,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD ) let urlSessionTransport = URLSessionTransport(urlSession: REST.makeURLSession()) + let shadowsocksCache = ShadowsocksConfigurationCache(cacheDirectory: containerURL) let transportProvider = TransportProvider( urlSessionTransport: urlSessionTransport, relayCache: relayCache, - addressCache: addressCache + addressCache: addressCache, + shadowsocksCache: shadowsocksCache ) transportMonitor = TransportMonitor( diff --git a/ios/MullvadVPNTests/AddressCacheTests.swift b/ios/MullvadVPNTests/AddressCacheTests.swift new file mode 100644 index 0000000000..d0e1d823b1 --- /dev/null +++ b/ios/MullvadVPNTests/AddressCacheTests.swift @@ -0,0 +1,138 @@ +// +// AddressCacheTests.swift +// MullvadRESTTests +// +// Created by Marco Nikic on 2023-05-05. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadREST +import MullvadTypes +import struct Network.IPv4Address +import XCTest + +final class AddressCacheTests: XCTestCase { + let apiEndpoint: AnyIPEndpoint = .ipv4(IPv4Endpoint(ip: IPv4Address.loopback, port: 80)) + + // MARK: - Tests + + func testAddressCacheHasDefaultEndpoint() { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .fileNotFound) + ) + XCTAssertEqual(addressCache.getCurrentEndpoint(), REST.defaultAPIEndpoint) + } + + func testSetEndpoints() throws { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .fileNotFound) + ) + + addressCache.setEndpoints([apiEndpoint]) + XCTAssertEqual(addressCache.getCurrentEndpoint(), apiEndpoint) + } + + func testSetEndpointsUpdatesDateWhenSettingSameAddress() throws { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .fileNotFound) + ) + addressCache.setEndpoints([apiEndpoint]) + + let dateBeforeUpdate = addressCache.getLastUpdateDate() + addressCache.setEndpoints([apiEndpoint]) + let dateAfterUpdate = addressCache.getLastUpdateDate() + + XCTAssertNotEqual(dateBeforeUpdate, dateAfterUpdate) + } + + func testSetEndpointsDoesNotDoAnythingIfSettingEmptyEndpoints() throws { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .fileNotFound) + ) + addressCache.loadFromFile() + + let currentEndpoint = addressCache.getCurrentEndpoint() + addressCache.setEndpoints([]) + + XCTAssertEqual(addressCache.getCurrentEndpoint(), currentEndpoint) + } + + func testSetEndpointsOnlyAcceptsTheFirstEndpoint() throws { + let ipAddresses = (1 ... 10) + .map { "\($0).\($0).\($0).\($0):80" } + .compactMap { AnyIPEndpoint(string: $0) } + + let firstIPEndpoint = try XCTUnwrap(ipAddresses.first) + + let fileCache = MockFileCache<REST.CachedAddresses>() + let addressCache = REST.AddressCache(canWriteToCache: true, fileCache: fileCache) + addressCache.setEndpoints(ipAddresses) + + let fileState = fileCache.getState() + XCTAssertTrue(fileState.isExists) + guard case let .exists(cachedAddresses) = fileState else { + XCTFail("State is expected to contain cached addresses.") + return + } + + XCTAssertEqual(cachedAddresses.endpoints.count, 1) + XCTAssertEqual(addressCache.getCurrentEndpoint(), firstIPEndpoint) + } + + func testCacheReadsFromFile() throws { + let fixedDate = Date() + let addressCache = REST.AddressCache( + canWriteToCache: true, + fileCache: MockFileCache(initialState: .exists( + REST.CachedAddresses(updatedAt: fixedDate, endpoints: [apiEndpoint]) + )) + ) + addressCache.loadFromFile() + + XCTAssertEqual(addressCache.getCurrentEndpoint(), apiEndpoint) + XCTAssertEqual(addressCache.getLastUpdateDate(), fixedDate) + } + + func testCacheWritesToDiskWhenSettingNewEndpoints() throws { + let fileCache = MockFileCache<REST.CachedAddresses>() + let addressCache = REST.AddressCache(canWriteToCache: true, fileCache: fileCache) + + XCTAssertEqual(fileCache.getState(), .fileNotFound) + addressCache.setEndpoints([apiEndpoint]) + + let fileState = fileCache.getState() + XCTAssertTrue(fileState.isExists) + + guard case let .exists(cachedAddresses) = fileState else { + XCTFail("State is expected to contain cached addresses.") + return + } + + XCTAssertEqual(cachedAddresses.endpoints, [addressCache.getCurrentEndpoint()]) + XCTAssertEqual(cachedAddresses.updatedAt, addressCache.getLastUpdateDate()) + } + + func testGetCurrentEndpointReadsFromCacheWhenReadOnly() throws { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .exists( + REST.CachedAddresses(updatedAt: Date(), endpoints: [apiEndpoint]) + )) + ) + XCTAssertEqual(addressCache.getCurrentEndpoint(), apiEndpoint) + } + + func testGetCurrentEndpointHasDefaultEndpointIfCacheIsEmpty() throws { + let addressCache = REST.AddressCache( + canWriteToCache: false, + fileCache: MockFileCache(initialState: .exists(REST.CachedAddresses(updatedAt: Date(), endpoints: []))) + ) + addressCache.loadFromFile() + + XCTAssertEqual(addressCache.getCurrentEndpoint(), REST.defaultAPIEndpoint) + } +} diff --git a/ios/MullvadVPNTests/FileCacheTests.swift b/ios/MullvadVPNTests/FileCacheTests.swift new file mode 100644 index 0000000000..dc3b250ed8 --- /dev/null +++ b/ios/MullvadVPNTests/FileCacheTests.swift @@ -0,0 +1,43 @@ +// +// FileCacheTests.swift +// MullvadVPNTests +// +// Created by pronebird on 13/06/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +@testable import MullvadTypes +import XCTest + +class FileCacheTests: XCTestCase { + var testFileURL: URL! + + override func setUp() { + testFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("FileCacheTest-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: testFileURL) + } + + func testRead() throws { + let stringData = UUID().uuidString + try JSONEncoder().encode(stringData).write(to: testFileURL) + + let fileCache = FileCache<String>(fileURL: testFileURL) + XCTAssertEqual(try fileCache.read(), stringData) + } + + func testWrite() throws { + let fileCache = FileCache<String>(fileURL: testFileURL) + + let stringData = UUID().uuidString + let serializedData = try JSONEncoder().encode(stringData) + + try fileCache.write(stringData) + + XCTAssertEqual(try Data(contentsOf: testFileURL), serializedData) + } +} diff --git a/ios/MullvadVPNTests/MockFileCache.swift b/ios/MullvadVPNTests/MockFileCache.swift new file mode 100644 index 0000000000..0b07c788e2 --- /dev/null +++ b/ios/MullvadVPNTests/MockFileCache.swift @@ -0,0 +1,63 @@ +// +// MockFileCache.swift +// MullvadVPNTests +// +// Created by pronebird on 13/06/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// File cache implementation that simulates file state and uses internal lock to synchronize access to it. +final class MockFileCache<Content: Codable & Equatable>: FileCacheProtocol { + private var state: State + private let stateLock = NSLock() + + init(initialState: State = .fileNotFound) { + state = initialState + } + + /// Returns internal state. + func getState() -> State { + stateLock.lock() + defer { stateLock.unlock() } + + return state + } + + func read() throws -> Content { + stateLock.lock() + defer { stateLock.unlock() } + + switch state { + case .fileNotFound: + throw CocoaError(.fileReadNoSuchFile) + case let .exists(content): + return content + } + } + + func write(_ content: Content) throws { + stateLock.lock() + defer { stateLock.unlock() } + + state = .exists(content) + } + + enum State: Equatable { + /// File does not exist yet. + case fileNotFound + + /// File exists with the given contents. + case exists(Content) + + var isExists: Bool { + if case .exists = self { + return true + } else { + return false + } + } + } +} diff --git a/ios/MullvadVPNTests/RelayCacheTests.swift b/ios/MullvadVPNTests/RelayCacheTests.swift new file mode 100644 index 0000000000..1a5d4804d4 --- /dev/null +++ b/ios/MullvadVPNTests/RelayCacheTests.swift @@ -0,0 +1,57 @@ +// +// RelayCacheTests.swift +// MullvadVPNTests +// +// Created by Marco Nikic on 2023-06-02. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import MullvadTransport +@testable import RelayCache +import XCTest + +final class RelayCacheTests: XCTestCase { + func testCanReadCache() throws { + let fileCache = MockFileCache( + initialState: .exists(CachedRelays(relays: .mock(), updatedAt: .distantPast)) + ) + let cache = RelayCache(fileCache: fileCache) + let relays = try XCTUnwrap(cache.read()) + + XCTAssertEqual(fileCache.getState(), .exists(relays)) + } + + func testCanWriteCache() throws { + let fileCache = MockFileCache( + initialState: .exists(CachedRelays(relays: .mock(), updatedAt: .distantPast)) + ) + let cache = RelayCache(fileCache: fileCache) + let newCachedRelays = CachedRelays(relays: .mock(), updatedAt: Date()) + + try cache.write(record: newCachedRelays) + XCTAssertEqual(fileCache.getState(), .exists(newCachedRelays)) + } + + func testCanReadPrebundledRelaysWhenNoCacheIsStored() throws { + let fileCache = MockFileCache<CachedRelays>(initialState: .fileNotFound) + let cache = RelayCache(fileCache: fileCache) + + XCTAssertNoThrow(try cache.read()) + } +} + +private extension REST.ServerRelaysResponse { + static func mock() -> Self { + return REST.ServerRelaysResponse( + locations: [:], + wireguard: REST.ServerWireguardTunnels( + ipv4Gateway: .loopback, + ipv6Gateway: .loopback, + portRanges: [], + relays: [] + ), + bridge: REST.ServerBridges(shadowsocks: [], relays: []) + ) + } +} diff --git a/ios/PacketTunnel/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider.swift index 820b59e7e6..e3bb561067 100644 --- a/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider.swift @@ -65,9 +65,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { private var tunnelStartupFailureRecoveryTimer: DispatchSourceTimer? /// Relay cache. - private let relayCache = RelayCache( - securityGroupIdentifier: ApplicationConfiguration.securityGroupIdentifier - )! + private let relayCache: RelayCache /// Current selector result. private var selectorResult: RelaySelectorResult? @@ -136,14 +134,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider, TunnelMonitorDelegate { providerLogger = Logger(label: "PacketTunnelProvider") tunnelLogger = Logger(label: "WireGuard") - let addressCache = REST.AddressCache(canWriteToCache: false, cacheFolder: ApplicationConfiguration.containerURL) + let containerURL = ApplicationConfiguration.containerURL + let addressCache = REST.AddressCache(canWriteToCache: false, cacheDirectory: containerURL) + addressCache.loadFromFile() + + relayCache = RelayCache(cacheDirectory: containerURL) let urlSession = REST.makeURLSession() let urlSessionTransport = URLSessionTransport(urlSession: urlSession) + let shadowsocksCache = ShadowsocksConfigurationCache(cacheDirectory: containerURL) let transportProvider = TransportProvider( urlSessionTransport: urlSessionTransport, relayCache: relayCache, - addressCache: addressCache + addressCache: addressCache, + shadowsocksCache: shadowsocksCache ) let proxyFactory = REST.ProxyFactory.makeProxyFactory( diff --git a/ios/RelayCache/CachedRelays.swift b/ios/RelayCache/CachedRelays.swift index 6ff9c36091..499eb9cde3 100644 --- a/ios/RelayCache/CachedRelays.swift +++ b/ios/RelayCache/CachedRelays.swift @@ -10,15 +10,15 @@ import Foundation import MullvadREST /// A struct that represents the relay cache on disk -public struct CachedRelays: Codable { +public struct CachedRelays: Codable, Equatable { /// E-tag returned by server - public var etag: String? + public let etag: String? /// The relay list stored within the cache entry - public var relays: REST.ServerRelaysResponse + public let relays: REST.ServerRelaysResponse /// The date when this cache was last updated - public var updatedAt: Date + public let updatedAt: Date public init(etag: String? = nil, relays: REST.ServerRelaysResponse, updatedAt: Date) { self.etag = etag diff --git a/ios/RelayCache/RelayCache.swift b/ios/RelayCache/RelayCache.swift index 3c5ceb3a20..abce823786 100644 --- a/ios/RelayCache/RelayCache.swift +++ b/ios/RelayCache/RelayCache.swift @@ -8,30 +8,26 @@ import Foundation import MullvadREST +import MullvadTypes public final class RelayCache { - /// Cache file location. - let cacheFileURL: URL + private let fileCache: any FileCacheProtocol<CachedRelays> - /// Location of pre-bundled relays file. - let prebundledRelaysFileURL: URL - - /// Initialize cache with default cache file location in app group container. - public init?(securityGroupIdentifier: String) { - guard let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: securityGroupIdentifier - ), let prebundledRelaysFileURL = Bundle(for: Self.self) - .url(forResource: "relays", withExtension: "json") else { return nil } + /// Designated initializer + public init(cacheDirectory: URL) { + fileCache = FileCache(fileURL: cacheDirectory.appendingPathComponent("relays.json", isDirectory: false)) + } - cacheFileURL = containerURL.appendingPathComponent("relays.json", isDirectory: false) - self.prebundledRelaysFileURL = prebundledRelaysFileURL + /// Initializer that accepts a custom FileCache implementation. Used in tests. + init(fileCache: some FileCacheProtocol<CachedRelays>) { + self.fileCache = fileCache } /// Safely read the cache file from disk using file coordinator and fallback to prebundled /// relays in case if the relay cache file is missing. public func read() throws -> CachedRelays { do { - return try readDiskCache() + return try fileCache.read() } catch { if error is DecodingError || (error as? CocoaError)?.code == .fileReadNoSuchFile { return try readPrebundledRelays() @@ -43,63 +39,16 @@ public final class RelayCache { /// Safely write the cache file on disk using file coordinator. public func write(record: CachedRelays) throws { - var result: Result<Void, Error>? - let fileCoordinator = NSFileCoordinator(filePresenter: nil) - - let accessor = { (fileURLForWriting: URL) in - result = Result { - let data = try JSONEncoder().encode(record) - try data.write(to: fileURLForWriting) - } - } - - var error: NSError? - fileCoordinator.coordinate( - writingItemAt: cacheFileURL, - options: [.forReplacing], - error: &error, - byAccessor: accessor - ) - - if let error { - result = .failure(error) - } - - try result?.get() - } - - /// Safely read the cache file from disk using file coordinator. - private func readDiskCache() throws -> CachedRelays { - var result: Result<CachedRelays, Error>? - let fileCoordinator = NSFileCoordinator(filePresenter: nil) - - let accessor = { (fileURLForReading: URL) in - result = Result { - let data = try Data(contentsOf: fileURLForReading) - return try JSONDecoder().decode(CachedRelays.self, from: data) - } - } - - var error: NSError? - fileCoordinator.coordinate( - readingItemAt: cacheFileURL, - options: [.withoutChanges], - error: &error, - byAccessor: accessor - ) - - if let error { - result = .failure(error) - } - - return try result!.get() + try fileCache.write(record) } /// Read pre-bundled relays file from disk. private func readPrebundledRelays() throws -> CachedRelays { + guard let prebundledRelaysFileURL = Bundle(for: Self.self).url(forResource: "relays", withExtension: "json") + else { throw CocoaError(.fileNoSuchFile) } + let data = try Data(contentsOf: prebundledRelaysFileURL) - let relays = try REST.Coding.makeJSONDecoder() - .decode(REST.ServerRelaysResponse.self, from: data) + let relays = try REST.Coding.makeJSONDecoder().decode(REST.ServerRelaysResponse.self, from: data) return CachedRelays( relays: relays, |
