From 3bd67ef5460eccd8e87088d17de1c5f70f35bc20 Mon Sep 17 00:00:00 2001 From: mojganii Date: Mon, 3 Jun 2024 13:21:53 +0200 Subject: [PATCH] Upgrade settings to associate with multi-hop --- .../NoRelaysSatisfyingConstraintsError.swift | 15 + .../Relay/RelaySelector+Shadowsocks.swift | 91 +++++ .../Relay/RelaySelector+Wireguard.swift | 78 ++++ ios/MullvadREST/Relay/RelaySelector.swift | 345 ++++++------------ .../Relay/RelaySelectorResult.swift | 18 + ios/MullvadREST/Relay/RelayWithDistance.swift | 13 + ios/MullvadREST/Relay/RelayWithLocation.swift | 31 ++ .../ShadowsocksConfigurationCache.swift | 16 +- .../Shadowsocks/ShadowsocksLoader.swift | 41 +-- .../ShadowsocksRelaySelector.swift | 60 +++ .../Transport/TransportStrategy.swift | 2 +- ios/MullvadRESTTests/Mocks/MemoryCache.swift | 2 + .../ShadowsocksLoaderStub.swift | 2 +- ios/MullvadSettings/MultihopSettings.swift | 77 ++++ ios/MullvadSettings/TunnelSettings.swift | 12 +- .../TunnelSettingsUpdate.swift | 4 + ios/MullvadSettings/TunnelSettingsV4.swift | 8 +- ios/MullvadSettings/TunnelSettingsV5.swift | 46 +++ ios/MullvadTypes/FileCache.swift | 5 + ios/MullvadTypes/RelayConstraints.swift | 37 +- ios/MullvadVPN.xcodeproj/project.pbxproj | 58 ++- ios/MullvadVPN/AppDelegate.swift | 19 +- .../ListCustomListCoordinator.swift | 11 +- .../Coordinators/LocationCoordinator.swift | 5 +- .../SimulatorTunnelProviderHost.swift | 6 +- .../TunnelManager/TunnelManager.swift | 6 +- .../Relay/RelaySelectorTests.swift | 128 ++++--- .../Shadowsocks/ShadowsocksLoaderTests.swift | 145 ++++++++ .../MigrationManagerTests.swift | 43 ++- .../MultihopUpdaterTests.swift | 31 ++ .../TunnelSettingsUpdateTests.swift | 14 +- .../MullvadTypes/MockFileCache.swift | 7 + .../MullvadTypes/RelayConstraintsTests.swift | 2 +- .../PacketTunnelProvider.swift | 31 +- .../RelaySelectorWrapper.swift | 55 ++- .../PacketTunnelProvider/SettingsReader.swift | 3 +- .../Protocols/SettingsReaderProtocol.swift | 7 +- .../AppMessageHandlerTests.swift | 8 +- .../Mocks/SettingsReaderStub.swift | 3 +- .../PacketTunnelActorTests.swift | 3 +- .../ProtocolObfuscatorTests.swift | 4 +- 41 files changed, 1112 insertions(+), 380 deletions(-) create mode 100644 ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift create mode 100644 ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift create mode 100644 ios/MullvadREST/Relay/RelaySelector+Wireguard.swift create mode 100644 ios/MullvadREST/Relay/RelaySelectorResult.swift create mode 100644 ios/MullvadREST/Relay/RelayWithDistance.swift create mode 100644 ios/MullvadREST/Relay/RelayWithLocation.swift create mode 100644 ios/MullvadREST/Transport/Shadowsocks/ShadowsocksRelaySelector.swift create mode 100644 ios/MullvadSettings/MultihopSettings.swift create mode 100644 ios/MullvadSettings/TunnelSettingsV5.swift create mode 100644 ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift create mode 100644 ios/MullvadVPNTests/MullvadSettings/MultihopUpdaterTests.swift diff --git a/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift new file mode 100644 index 000000000000..9435929db687 --- /dev/null +++ b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift @@ -0,0 +1,15 @@ +// +// NoRelaysSatisfyingConstraintsError.swift +// MullvadREST +// +// Created by Mojgan on 2024-04-26. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +public struct NoRelaysSatisfyingConstraintsError: LocalizedError { + public var errorDescription: String? { + "No relays satisfying constraints." + } +} diff --git a/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift new file mode 100644 index 000000000000..273b9afe03cc --- /dev/null +++ b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift @@ -0,0 +1,91 @@ +// +// RelaySelector+Shadowsocks.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +extension RelaySelector { + public enum Shadowsocks { + /** + Returns random shadowsocks TCP bridge, otherwise `nil` if there are no shadowdsocks bridges. + */ + public static func tcpBridge(from relays: REST.ServerRelaysResponse) -> REST.ServerShadowsocks? { + relays.bridge.shadowsocks.filter { $0.protocol == "tcp" }.randomElement() + } + + /// Return a random Shadowsocks bridge relay, or `nil` if no relay were found. + /// + /// Non `active` relays are filtered out. + /// - Parameter relays: The list of relays to randomly select from. + /// - Returns: A Shadowsocks relay or `nil` if no active relay were found. + public static func relay(from relaysResponse: REST.ServerRelaysResponse) -> REST.BridgeRelay? { + relaysResponse.bridge.relays.filter { $0.active }.randomElement() + } + + /// Returns the closest Shadowsocks relay using the given `location`, or a random relay if `constraints` were + /// unsatisfiable. + /// + /// - Parameters: + /// - location: The user selected `location` + /// - port: The user selected port + /// - filter: The user filtered criteria + /// - relays: The list of relays to randomly select from. + /// - Returns: A Shadowsocks relay or `nil` if no active relay were found. + public static func closestRelay( + location: RelayConstraint, + port: RelayConstraint, + filter: RelayConstraint, + in relaysResponse: REST.ServerRelaysResponse + ) -> REST.BridgeRelay? { + let mappedBridges = mapRelays(relays: relaysResponse.bridge.relays, locations: relaysResponse.locations) + let filteredRelays = applyConstraints( + location, + portConstraint: port, + filterConstraint: filter, + relays: mappedBridges + ) + guard filteredRelays.isEmpty == false else { return relay(from: relaysResponse) } + + // Compute the midpoint location from all the filtered relays + // Take *either* the first five relays, OR the relays below maximum bridge distance + // sort all of them by Haversine distance from the computed midpoint location + // then use the roulette selection to pick a bridge + + let midpointDistance = Midpoint.location(in: filteredRelays.map { $0.serverLocation.geoCoordinate }) + let maximumBridgeDistance = 1500.0 + let relaysWithDistance = filteredRelays.map { + RelayWithDistance( + relay: $0.relay, + distance: Haversine.distance( + midpointDistance.latitude, + midpointDistance.longitude, + $0.serverLocation.latitude, + $0.serverLocation.longitude + ) + ) + }.sorted { + $0.distance < $1.distance + }.filter { + $0.distance <= maximumBridgeDistance + }.prefix(5) + + var greatestDistance = 0.0 + relaysWithDistance.forEach { + if $0.distance > greatestDistance { + greatestDistance = $0.distance + } + } + + let randomRelay = rouletteSelection(relays: Array(relaysWithDistance), weightFunction: { relay in + UInt64(1 + greatestDistance - relay.distance) + }) + + return randomRelay?.relay ?? filteredRelays.randomElement()?.relay + } + } +} diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift new file mode 100644 index 000000000000..4607838ac292 --- /dev/null +++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift @@ -0,0 +1,78 @@ +// +// RelaySelector+Wireguard.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +extension RelaySelector { + public enum WireGuard { + /** + Filters relay list using given constraints and selects random relay for exit relay. + Throws an error if there are no relays satisfying the given constraints. + */ + public static func evaluate( + by constraints: RelayConstraints, + in relaysResponse: REST.ServerRelaysResponse, + numberOfFailedAttempts: UInt + ) throws -> RelaySelectorResult { + let exitCandidates = try findBestMatch( + relays: relaysResponse, + relayConstraint: constraints.exitLocations, + portConstraint: constraints.port, + filterConstraint: constraints.filter, + numberOfFailedAttempts: numberOfFailedAttempts + ) + + return exitCandidates + } + + // MARK: - private functions + + private static func findBestMatch( + relays: REST.ServerRelaysResponse, + relayConstraint: RelayConstraint, + portConstraint: RelayConstraint, + filterConstraint: RelayConstraint, + numberOfFailedAttempts: UInt + ) throws -> RelaySelectorMatch { + let mappedRelays = mapRelays(relays: relays.wireguard.relays, locations: relays.locations) + let filteredRelays = applyConstraints( + relayConstraint, + portConstraint: portConstraint, + filterConstraint: filterConstraint, + relays: mappedRelays + ) + let port = applyPortConstraint( + portConstraint, + rawPortRanges: relays.wireguard.portRanges, + numberOfFailedAttempts: numberOfFailedAttempts + ) + + guard let relayWithLocation = pickRandomRelayByWeight(relays: filteredRelays), let port else { + throw NoRelaysSatisfyingConstraintsError() + } + + let endpoint = MullvadEndpoint( + ipv4Relay: IPv4Endpoint( + ip: relayWithLocation.relay.ipv4AddrIn, + port: port + ), + ipv6Relay: nil, + ipv4Gateway: relays.wireguard.ipv4Gateway, + ipv6Gateway: relays.wireguard.ipv6Gateway, + publicKey: relayWithLocation.relay.publicKey + ) + + return RelaySelectorMatch( + endpoint: endpoint, + relay: relayWithLocation.relay, + location: relayWithLocation.serverLocation + ) + } + } +} diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift index 18dce0e83b35..44062134ccc2 100644 --- a/ios/MullvadREST/Relay/RelaySelector.swift +++ b/ios/MullvadREST/Relay/RelaySelector.swift @@ -12,112 +12,7 @@ import MullvadTypes private let defaultPort: UInt16 = 53 public enum RelaySelector { - /** - Returns random shadowsocks TCP bridge, otherwise `nil` if there are no shadowdsocks bridges. - */ - public static func shadowsocksTCPBridge(from relays: REST.ServerRelaysResponse) -> REST.ServerShadowsocks? { - relays.bridge.shadowsocks.filter { $0.protocol == "tcp" }.randomElement() - } - - /// Return a random Shadowsocks bridge relay, or `nil` if no relay were found. - /// - /// Non `active` relays are filtered out. - /// - Parameter relays: The list of relays to randomly select from. - /// - Returns: A Shadowsocks relay or `nil` if no active relay were found. - public static func shadowsocksRelay(from relaysResponse: REST.ServerRelaysResponse) -> REST.BridgeRelay? { - relaysResponse.bridge.relays.filter { $0.active }.randomElement() - } - - /// Returns the closest Shadowsocks relay using the given `constraints`, or a random relay if `constraints` were - /// unsatisfiable. - /// - /// - Parameters: - /// - constraints: The user selected `constraints` - /// - relays: The list of relays to randomly select from. - /// - Returns: A Shadowsocks relay or `nil` if no active relay were found. - public static func closestShadowsocksRelayConstrained( - by constraints: RelayConstraints, - in relaysResponse: REST.ServerRelaysResponse - ) -> REST.BridgeRelay? { - let mappedBridges = mapRelays(relays: relaysResponse.bridge.relays, locations: relaysResponse.locations) - let filteredRelays = applyConstraints(constraints, relays: mappedBridges) - guard filteredRelays.isEmpty == false else { return shadowsocksRelay(from: relaysResponse) } - - // Compute the midpoint location from all the filtered relays - // Take *either* the first five relays, OR the relays below maximum bridge distance - // sort all of them by Haversine distance from the computed midpoint location - // then use the roulette selection to pick a bridge - - let midpointDistance = Midpoint.location(in: filteredRelays.map { $0.serverLocation.geoCoordinate }) - let maximumBridgeDistance = 1500.0 - let relaysWithDistance = filteredRelays.map { - RelayWithDistance( - relay: $0.relay, - distance: Haversine.distance( - midpointDistance.latitude, - midpointDistance.longitude, - $0.serverLocation.latitude, - $0.serverLocation.longitude - ) - ) - }.sorted { - $0.distance < $1.distance - }.filter { - $0.distance <= maximumBridgeDistance - }.prefix(5) - - var greatestDistance = 0.0 - relaysWithDistance.forEach { - if $0.distance > greatestDistance { - greatestDistance = $0.distance - } - } - - let randomRelay = rouletteSelection(relays: Array(relaysWithDistance), weightFunction: { relay in - UInt64(1 + greatestDistance - relay.distance) - }) - - return randomRelay?.relay ?? filteredRelays.randomElement()?.relay - } - - /** - Filters relay list using given constraints and selects random relay. - Throws an error if there are no relays satisfying the given constraints. - */ - public static func evaluate( - relays: REST.ServerRelaysResponse, - constraints: RelayConstraints, - numberOfFailedAttempts: UInt - ) throws -> RelaySelectorResult { - let mappedRelays = mapRelays(relays: relays.wireguard.relays, locations: relays.locations) - let filteredRelays = applyConstraints(constraints, relays: mappedRelays) - let port = applyConstraints( - constraints, - rawPortRanges: relays.wireguard.portRanges, - numberOfFailedAttempts: numberOfFailedAttempts - ) - - guard let relayWithLocation = pickRandomRelayByWeight(relays: filteredRelays), let port else { - throw NoRelaysSatisfyingConstraintsError() - } - - let endpoint = MullvadEndpoint( - ipv4Relay: IPv4Endpoint( - ip: relayWithLocation.relay.ipv4AddrIn, - port: port - ), - ipv6Relay: nil, - ipv4Gateway: relays.wireguard.ipv4Gateway, - ipv6Gateway: relays.wireguard.ipv6Gateway, - publicKey: relayWithLocation.relay.publicKey - ) - - return RelaySelectorResult( - endpoint: endpoint, - relay: relayWithLocation.relay, - location: relayWithLocation.serverLocation - ) - } + // MARK: - public /// Determines whether a `REST.ServerRelay` satisfies the given relay filter. public static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool { @@ -135,86 +30,37 @@ public enum RelaySelector { } } - /// Produce a list of `RelayWithLocation` items satisfying the given constraints - static func applyConstraints( - _ constraints: RelayConstraints, - relays: [RelayWithLocation] - ) -> [RelayWithLocation] { - // Filter on active status, filter, and location. - let filteredRelays = relays.filter { relayWithLocation -> Bool in - guard relayWithLocation.relay.active else { - return false - } + // MARK: - private - switch constraints.filter { - case .any: - break - case let .only(filter): - if !relayMatchesFilter(relayWithLocation.relay, filter: filter) { - return false - } - } + static func pickRandomRelayByWeight(relays: [RelayWithLocation]) + -> RelayWithLocation? { + rouletteSelection(relays: relays, weightFunction: { relayWithLocation in relayWithLocation.relay.weight }) + } - return switch constraints.locations { - case .any: - true - case let .only(relayConstraint): - // At least one location must match the relay under test. - relayConstraint.locations.contains { location in - relayWithLocation.matches(location: location) - } - } + private static func pickRandomPort(rawPortRanges: [[UInt16]]) -> UInt16? { + let portRanges = parseRawPortRanges(rawPortRanges) + let portAmount = portRanges.reduce(0) { partialResult, closedRange in + partialResult + closedRange.count } - // Filter on country inclusion. - let includeInCountryFilteredRelays = filteredRelays.filter { relayWithLocation in - return switch constraints.locations { - case .any: - true - case let .only(relayConstraint): - relayConstraint.locations.contains { location in - if case .country = location { - return relayWithLocation.relay.includeInCountry - } - return false - } - } + guard var portIndex = (0 ..< portAmount).randomElement() else { + return nil } - // If no relays should be included in the matched country, instead accept all. - if includeInCountryFilteredRelays.isEmpty { - return filteredRelays - } else { - return includeInCountryFilteredRelays + for range in portRanges { + if portIndex < range.count { + return UInt16(portIndex) + range.lowerBound + } else { + portIndex -= range.count + } } - } - - /// Produce a port that is either user provided or randomly selected, satisfying the given constraints. - private static func applyConstraints( - _ constraints: RelayConstraints, - rawPortRanges: [[UInt16]], - numberOfFailedAttempts: UInt - ) -> UInt16? { - switch constraints.port { - case let .only(port): - return port - case .any: - // 1. First two attempts should pick a random port. - // 2. The next two should pick port 53. - // 3. Repeat steps 1 and 2. - let useDefaultPort = (numberOfFailedAttempts % 4 == 2) || (numberOfFailedAttempts % 4 == 3) - - return useDefaultPort ? defaultPort : pickRandomPort(rawPortRanges: rawPortRanges) - } - } + assertionFailure("Port selection algorithm is broken!") - private static func pickRandomRelayByWeight(relays: [RelayWithLocation]) - -> RelayWithLocation? { - rouletteSelection(relays: relays, weightFunction: { relayWithLocation in relayWithLocation.relay.weight }) + return nil } - private static func rouletteSelection(relays: [T], weightFunction: (T) -> UInt64) -> T? { + static func rouletteSelection(relays: [T], weightFunction: (T) -> UInt64) -> T? { let totalWeight = relays.map { weightFunction($0) }.reduce(0) { accumulated, weight in accumulated + weight } @@ -241,45 +87,7 @@ public enum RelaySelector { return randomRelay } - private static func pickRandomPort(rawPortRanges: [[UInt16]]) -> UInt16? { - let portRanges = parseRawPortRanges(rawPortRanges) - let portAmount = portRanges.reduce(0) { partialResult, closedRange in - partialResult + closedRange.count - } - - guard var portIndex = (0 ..< portAmount).randomElement() else { - return nil - } - - for range in portRanges { - if portIndex < range.count { - return UInt16(portIndex) + range.lowerBound - } else { - portIndex -= range.count - } - } - - assertionFailure("Port selection algorithm is broken!") - - return nil - } - - private static func parseRawPortRanges(_ rawPortRanges: [[UInt16]]) -> [ClosedRange] { - rawPortRanges.compactMap { inputRange -> ClosedRange? in - guard inputRange.count == 2 else { return nil } - - let startPort = inputRange[0] - let endPort = inputRange[1] - - if startPort <= endPort { - return startPort ... endPort - } else { - return nil - } - } - } - - private static func mapRelays( + static func mapRelays( relays: [T], locations: [String: REST.ServerLocation] ) -> [RelayWithLocation] { @@ -307,42 +115,95 @@ public enum RelaySelector { return RelayWithLocation(relay: relay, serverLocation: location) } -} -public struct NoRelaysSatisfyingConstraintsError: LocalizedError { - public var errorDescription: String? { - "No relays satisfying constraints." + private static func parseRawPortRanges(_ rawPortRanges: [[UInt16]]) -> [ClosedRange] { + rawPortRanges.compactMap { inputRange -> ClosedRange? in + guard inputRange.count == 2 else { return nil } + + let startPort = inputRange[0] + let endPort = inputRange[1] + + if startPort <= endPort { + return startPort ... endPort + } else { + return nil + } + } } -} -public struct RelaySelectorResult: Codable, Equatable { - public var endpoint: MullvadEndpoint - public var relay: REST.ServerRelay - public var location: Location -} + /// Produce a list of `RelayWithLocation` items satisfying the given constraints + static func applyConstraints( + _ relayConstraint: RelayConstraint, + portConstraint: RelayConstraint, + filterConstraint: RelayConstraint, + relays: [RelayWithLocation] + ) -> [RelayWithLocation] { + // Filter on active status, filter, and location. + let filteredRelays = relays.filter { relayWithLocation -> Bool in + guard relayWithLocation.relay.active else { + return false + } -struct RelayWithLocation { - let relay: T - let serverLocation: Location + switch filterConstraint { + case .any: + break + case let .only(filter): + if !relayMatchesFilter(relayWithLocation.relay, filter: filter) { + return false + } + } - func matches(location: RelayLocation) -> Bool { - return switch location { - case let .country(countryCode): - serverLocation.countryCode == countryCode + return switch relayConstraint { + case .any: + true + case let .only(relayConstraint): + // At least one location must match the relay under test. + relayConstraint.locations.contains { location in + relayWithLocation.matches(location: location) + } + } + } - case let .city(countryCode, cityCode): - serverLocation.countryCode == countryCode && - serverLocation.cityCode == cityCode + // Filter on country inclusion. + let includeInCountryFilteredRelays = filteredRelays.filter { relayWithLocation in + return switch relayConstraint { + case .any: + true + case let .only(relayConstraint): + relayConstraint.locations.contains { location in + if case .country = location { + return relayWithLocation.relay.includeInCountry + } + return false + } + } + } - case let .hostname(countryCode, cityCode, hostname): - serverLocation.countryCode == countryCode && - serverLocation.cityCode == cityCode && - relay.hostname == hostname + // If no relays should be included in the matched country, instead accept all. + if includeInCountryFilteredRelays.isEmpty { + return filteredRelays + } else { + return includeInCountryFilteredRelays } } -} -private struct RelayWithDistance { - let relay: T - let distance: Double + /// Produce a port that is either user provided or randomly selected, satisfying the given constraints. + static func applyPortConstraint( + _ portConstraint: RelayConstraint, + rawPortRanges: [[UInt16]], + numberOfFailedAttempts: UInt + ) -> UInt16? { + switch portConstraint { + case let .only(port): + return port + + case .any: + // 1. First two attempts should pick a random port. + // 2. The next two should pick port 53. + // 3. Repeat steps 1 and 2. + let useDefaultPort = (numberOfFailedAttempts % 4 == 2) || (numberOfFailedAttempts % 4 == 3) + + return useDefaultPort ? defaultPort : pickRandomPort(rawPortRanges: rawPortRanges) + } + } } diff --git a/ios/MullvadREST/Relay/RelaySelectorResult.swift b/ios/MullvadREST/Relay/RelaySelectorResult.swift new file mode 100644 index 000000000000..3e9ffa4bb013 --- /dev/null +++ b/ios/MullvadREST/Relay/RelaySelectorResult.swift @@ -0,0 +1,18 @@ +// +// RelaySelectorResult.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +public typealias RelaySelectorResult = RelaySelectorMatch + +public struct RelaySelectorMatch: Codable, Equatable { + public var endpoint: MullvadEndpoint + public var relay: REST.ServerRelay + public var location: Location +} diff --git a/ios/MullvadREST/Relay/RelayWithDistance.swift b/ios/MullvadREST/Relay/RelayWithDistance.swift new file mode 100644 index 000000000000..aadf5fd565c0 --- /dev/null +++ b/ios/MullvadREST/Relay/RelayWithDistance.swift @@ -0,0 +1,13 @@ +// +// RelayWithDistance.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +struct RelayWithDistance { + let relay: T + let distance: Double +} diff --git a/ios/MullvadREST/Relay/RelayWithLocation.swift b/ios/MullvadREST/Relay/RelayWithLocation.swift new file mode 100644 index 000000000000..c80cc34a3a7d --- /dev/null +++ b/ios/MullvadREST/Relay/RelayWithLocation.swift @@ -0,0 +1,31 @@ +// +// RelayWithLocation.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +struct RelayWithLocation { + let relay: T + let serverLocation: Location + + func matches(location: RelayLocation) -> Bool { + return switch location { + case let .country(countryCode): + serverLocation.countryCode == countryCode + + case let .city(countryCode, cityCode): + serverLocation.countryCode == countryCode && + serverLocation.cityCode == cityCode + + case let .hostname(countryCode, cityCode, hostname): + serverLocation.countryCode == countryCode && + serverLocation.cityCode == cityCode && + relay.hostname == hostname + } + } +} diff --git a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksConfigurationCache.swift b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksConfigurationCache.swift index b09f4afdf66e..e5c68b631cb2 100644 --- a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksConfigurationCache.swift +++ b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksConfigurationCache.swift @@ -9,8 +9,14 @@ import Foundation import MullvadTypes +public protocol ShadowsocksConfigurationCacheProtocol { + func read() throws -> ShadowsocksConfiguration + func write(_ configuration: ShadowsocksConfiguration) throws + func clear() throws +} + /// Holds a shadowsocks configuration object backed by a caching mechanism shared across processes -public final class ShadowsocksConfigurationCache { +public final class ShadowsocksConfigurationCache: ShadowsocksConfigurationCacheProtocol { private let configurationLock = NSLock() private var cachedConfiguration: ShadowsocksConfiguration? private let fileCache: FileCache @@ -44,4 +50,12 @@ public final class ShadowsocksConfigurationCache { cachedConfiguration = configuration try fileCache.write(configuration) } + + /// Clear cached configuration. + public func clear() throws { + configurationLock.lock() + defer { configurationLock.unlock() } + cachedConfiguration = nil + try fileCache.clear() + } } diff --git a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift index 839e3b524fa5..2525fe306401 100644 --- a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift +++ b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift @@ -7,61 +7,60 @@ // import Foundation +import MullvadSettings import MullvadTypes public protocol ShadowsocksLoaderProtocol { func load() throws -> ShadowsocksConfiguration - func reloadConfiguration() throws + func clear() throws } public class ShadowsocksLoader: ShadowsocksLoaderProtocol { - private let shadowsocksCache: ShadowsocksConfigurationCache - private let relayCache: RelayCacheProtocol + let cache: ShadowsocksConfigurationCacheProtocol + let relaySelector: ShadowsocksRelaySelectorProtocol + let constraintsUpdater: RelayConstraintsUpdater + private var relayConstraints = RelayConstraints() - private let constraintsUpdater: RelayConstraintsUpdater public init( - shadowsocksCache: ShadowsocksConfigurationCache, - relayCache: RelayCacheProtocol, + cache: ShadowsocksConfigurationCacheProtocol, + relaySelector: ShadowsocksRelaySelectorProtocol, constraintsUpdater: RelayConstraintsUpdater ) { - self.shadowsocksCache = shadowsocksCache - self.relayCache = relayCache + self.cache = cache + self.relaySelector = relaySelector self.constraintsUpdater = constraintsUpdater + constraintsUpdater.onNewConstraints = { [weak self] newConstraints in self?.relayConstraints = newConstraints + try? self?.cache.clear() } } - public func reloadConfiguration() throws { - let newConfiguration = try create() - try shadowsocksCache.write(newConfiguration) + public func clear() throws { + try self.cache.clear() } /// Returns the last used shadowsocks configuration, otherwise a new randomized configuration. public func load() throws -> ShadowsocksConfiguration { do { // If a previous shadowsocks configuration was in cache, return it directly. - return try shadowsocksCache.read() + return try cache.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. let newConfiguration = try create() - try shadowsocksCache.write(newConfiguration) + try cache.write(newConfiguration) return newConfiguration } } /// Returns a randomly selected shadowsocks configuration. private func create() throws -> ShadowsocksConfiguration { - let cachedRelays = try relayCache.read() - let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays) - let closestRelay = RelaySelector.closestShadowsocksRelayConstrained( - by: relayConstraints, - in: cachedRelays.relays - ) + let bridgeConfiguration = try relaySelector.getBridges() + let closestRelay = try relaySelector.selectRelay(with: relayConstraints) - guard let bridgeAddress = closestRelay?.ipv4AddrIn, let bridgeConfiguration else { throw POSIXError(.ENOENT) } + guard let bridgeAddress = closestRelay?.ipv4AddrIn, + let bridgeConfiguration else { throw POSIXError(.ENOENT) } return ShadowsocksConfiguration( address: .ipv4(bridgeAddress), diff --git a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksRelaySelector.swift b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksRelaySelector.swift new file mode 100644 index 000000000000..98d9e712e72f --- /dev/null +++ b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksRelaySelector.swift @@ -0,0 +1,60 @@ +// +// ShadowsocksRelaySelector.swift +// MullvadREST +// +// Created by Mojgan on 2024-05-23. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings +import MullvadTypes + +public protocol ShadowsocksRelaySelectorProtocol { + func selectRelay( + with constraints: RelayConstraints + ) throws -> REST.BridgeRelay? + + func getBridges() throws -> REST.ServerShadowsocks? +} + +final public class ShadowsocksRelaySelector: ShadowsocksRelaySelectorProtocol { + let relayCache: RelayCacheProtocol + let multihopUpdater: MultihopUpdater + private var multihopState: MultihopState = .off + + public init( + relayCache: RelayCacheProtocol, + multihopUpdater: MultihopUpdater + ) { + self.relayCache = relayCache + self.multihopUpdater = multihopUpdater + + multihopUpdater.addObserver(MultihopObserverBlock(didUpdateMultihop: { [weak self] _, multihopState in + self?.multihopState = multihopState + })) + } + + public func selectRelay( + with constraints: RelayConstraints + ) throws -> REST.BridgeRelay? { + let cachedRelays = try relayCache.read().relays + + let locationConstraint = switch multihopState { + case .on: constraints.entryLocations + case .off: constraints.exitLocations + } + + return RelaySelector.Shadowsocks.closestRelay( + location: locationConstraint, + port: constraints.port, + filter: constraints.filter, + in: cachedRelays + ) + } + + public func getBridges() throws -> REST.ServerShadowsocks? { + let cachedRelays = try relayCache.read() + return RelaySelector.Shadowsocks.tcpBridge(from: cachedRelays.relays) + } +} diff --git a/ios/MullvadREST/Transport/TransportStrategy.swift b/ios/MullvadREST/Transport/TransportStrategy.swift index 72920ddcd5ba..a1d029b2b6ef 100644 --- a/ios/MullvadREST/Transport/TransportStrategy.swift +++ b/ios/MullvadREST/Transport/TransportStrategy.swift @@ -57,7 +57,7 @@ public struct TransportStrategy: Equatable { let configuration = accessMethodIterator.pick() switch configuration.kind { case .bridges: - try? shadowsocksLoader.reloadConfiguration() + try? shadowsocksLoader.clear() fallthrough default: self.accessMethodIterator.rotate() diff --git a/ios/MullvadRESTTests/Mocks/MemoryCache.swift b/ios/MullvadRESTTests/Mocks/MemoryCache.swift index f01daa32b7e5..d0e495f8891f 100644 --- a/ios/MullvadRESTTests/Mocks/MemoryCache.swift +++ b/ios/MullvadRESTTests/Mocks/MemoryCache.swift @@ -18,4 +18,6 @@ struct MemoryCache: FileCacheProtocol { } func write(_ content: REST.StoredAddressCache) throws {} + + func clear() throws {} } diff --git a/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift b/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift index 4442ddf63f7a..973ea954a05b 100644 --- a/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift +++ b/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift @@ -15,7 +15,7 @@ struct ShadowsocksLoaderStub: ShadowsocksLoaderProtocol { var configuration: ShadowsocksConfiguration var error: Error? - func reloadConfiguration() throws { + func clear() throws { try load() } diff --git a/ios/MullvadSettings/MultihopSettings.swift b/ios/MullvadSettings/MultihopSettings.swift new file mode 100644 index 000000000000..21ceefa1635a --- /dev/null +++ b/ios/MullvadSettings/MultihopSettings.swift @@ -0,0 +1,77 @@ +// +// MultihopSettings.swift +// MullvadSettings +// +// Created by Mojgan on 2024-04-26. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +public protocol MultihopPropagation { + typealias MultihopHandler = (MultihopState) -> Void + var onNewMultihop: MultihopHandler? { get set } +} + +public protocol MultihopObserver: AnyObject { + func multihop(_ object: MultihopPropagation, didUpdateMultihop state: MultihopState) +} + +public class MultihopObserverBlock: MultihopObserver { + public typealias DidUpdateMultihopHandler = (MultihopPropagation, MultihopState) -> Void + public var onNewState: DidUpdateMultihopHandler + + public init(didUpdateMultihop: @escaping DidUpdateMultihopHandler) { + self.onNewState = didUpdateMultihop + } + + public func multihop(_ object: MultihopPropagation, didUpdateMultihop state: MultihopState) { + self.onNewState(object, state) + } +} + +public final class MultihopStateListener: MultihopPropagation { + public var onNewMultihop: MultihopHandler? + + public init(onNewMultihop: MultihopHandler? = nil) { + self.onNewMultihop = onNewMultihop + } +} + +public class MultihopUpdater { + private let mutex = NSLock() + private var observerList: [MultihopObserver] = [] + private var listener: MultihopPropagation + + public init(listener: MultihopPropagation) { + self.listener = listener + self.listener.onNewMultihop = { [weak self] state in + guard let self else { return } + self.observerList.forEach { + $0.multihop(listener, didUpdateMultihop: state) + } + } + } + + // MARK: - Multihop observations + + public func addObserver(_ observer: MultihopObserver) { + mutex.lock() + observerList.append(observer) + mutex.unlock() + } + + public func removeObserver(_ observer: MultihopObserver) { + mutex.lock() + if let index = observerList.firstIndex(where: { $0 === observer }) { + observerList.remove(at: index) + } + mutex.unlock() + } +} + +/// Whether Multi-hop is enabled +public enum MultihopState: Codable { + case on + case off +} diff --git a/ios/MullvadSettings/TunnelSettings.swift b/ios/MullvadSettings/TunnelSettings.swift index b8a276cb7cbc..853e3dc70ee3 100644 --- a/ios/MullvadSettings/TunnelSettings.swift +++ b/ios/MullvadSettings/TunnelSettings.swift @@ -9,7 +9,7 @@ import Foundation /// Alias to the latest version of the `TunnelSettings`. -public typealias LatestTunnelSettings = TunnelSettingsV4 +public typealias LatestTunnelSettings = TunnelSettingsV5 /// Protocol all TunnelSettings must adhere to, for upgrade purposes. public protocol TunnelSettings: Codable { @@ -27,14 +27,19 @@ public enum SchemaVersion: Int, Equatable { /// V2 format with WireGuard obfuscation options, stored as `TunnelSettingsV3`. case v3 = 3 + /// V3 format with post quantum options, stored as `TunnelSettingsV4`. case v4 = 4 + /// V4 format with multi-hop options, stored as `TunnelSettingsV5`. + case v5 = 5 + var settingsType: any TunnelSettings.Type { switch self { case .v1: return TunnelSettingsV1.self case .v2: return TunnelSettingsV2.self case .v3: return TunnelSettingsV3.self case .v4: return TunnelSettingsV4.self + case .v5: return TunnelSettingsV5.self } } @@ -43,10 +48,11 @@ public enum SchemaVersion: Int, Equatable { case .v1: return .v2 case .v2: return .v3 case .v3: return .v4 - case .v4: return .v4 + case .v4: return .v5 + case .v5: return .v5 } } /// Current schema version. - public static let current = SchemaVersion.v4 + public static let current = SchemaVersion.v5 } diff --git a/ios/MullvadSettings/TunnelSettingsUpdate.swift b/ios/MullvadSettings/TunnelSettingsUpdate.swift index c915e5dc7f5f..92349a38ed79 100644 --- a/ios/MullvadSettings/TunnelSettingsUpdate.swift +++ b/ios/MullvadSettings/TunnelSettingsUpdate.swift @@ -14,6 +14,7 @@ public enum TunnelSettingsUpdate { case obfuscation(WireGuardObfuscationSettings) case relayConstraints(RelayConstraints) case quantumResistance(TunnelQuantumResistance) + case multihop(MultihopState) } extension TunnelSettingsUpdate { @@ -27,6 +28,8 @@ extension TunnelSettingsUpdate { settings.relayConstraints = newRelayConstraints case let .quantumResistance(newQuantumResistance): settings.tunnelQuantumResistance = newQuantumResistance + case let .multihop(newState): + settings.tunnelMultihopState = newState } } @@ -36,6 +39,7 @@ extension TunnelSettingsUpdate { case .obfuscation: "obfuscation settings" case .relayConstraints: "relay constraints" case .quantumResistance: "quantum resistance" + case .multihop: "Multihop" } } } diff --git a/ios/MullvadSettings/TunnelSettingsV4.swift b/ios/MullvadSettings/TunnelSettingsV4.swift index 0d938bc27974..9b75a7ebf37e 100644 --- a/ios/MullvadSettings/TunnelSettingsV4.swift +++ b/ios/MullvadSettings/TunnelSettingsV4.swift @@ -35,6 +35,12 @@ public struct TunnelSettingsV4: Codable, Equatable, TunnelSettings { } public func upgradeToNextVersion() -> any TunnelSettings { - self + TunnelSettingsV5( + relayConstraints: relayConstraints, + dnsSettings: dnsSettings, + wireGuardObfuscation: wireGuardObfuscation, + tunnelQuantumResistance: tunnelQuantumResistance, + tunnelMultihopState: .off + ) } } diff --git a/ios/MullvadSettings/TunnelSettingsV5.swift b/ios/MullvadSettings/TunnelSettingsV5.swift new file mode 100644 index 000000000000..e8035d0b6629 --- /dev/null +++ b/ios/MullvadSettings/TunnelSettingsV5.swift @@ -0,0 +1,46 @@ +// +// TunnelSettingsV5.swift +// MullvadSettings +// +// Created by Mojgan on 2024-05-13. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +public struct TunnelSettingsV5: Codable, Equatable, TunnelSettings { + /// Relay constraints. + public var relayConstraints: RelayConstraints + + /// DNS settings. + public var dnsSettings: DNSSettings + + /// WireGuard obfuscation settings + public var wireGuardObfuscation: WireGuardObfuscationSettings + + /// Whether Post Quantum exchanges are enabled. + public var tunnelQuantumResistance: TunnelQuantumResistance + + /// Whether Multi-hop is enabled. + public var tunnelMultihopState: MultihopState + + public init( + relayConstraints: RelayConstraints = RelayConstraints(), + dnsSettings: DNSSettings = DNSSettings(), + wireGuardObfuscation: WireGuardObfuscationSettings = WireGuardObfuscationSettings(), + tunnelQuantumResistance: TunnelQuantumResistance = .automatic, + tunnelMultihopState: MultihopState = .off + + ) { + self.relayConstraints = relayConstraints + self.dnsSettings = dnsSettings + self.wireGuardObfuscation = wireGuardObfuscation + self.tunnelQuantumResistance = tunnelQuantumResistance + self.tunnelMultihopState = tunnelMultihopState + } + + public func upgradeToNextVersion() -> any TunnelSettings { + self + } +} diff --git a/ios/MullvadTypes/FileCache.swift b/ios/MullvadTypes/FileCache.swift index 381bfb41a454..29bb897f3499 100644 --- a/ios/MullvadTypes/FileCache.swift +++ b/ios/MullvadTypes/FileCache.swift @@ -31,6 +31,10 @@ public struct FileCache: FileCacheProtocol { try JSONEncoder().encode(content).write(to: fileURL) } } + + public func clear() throws { + try FileManager.default.removeItem(at: fileURL) + } } /// Protocol describing file cache that's able to read and write serializable content. @@ -39,4 +43,5 @@ public protocol FileCacheProtocol { func read() throws -> Content func write(_ content: Content) throws + func clear() throws } diff --git a/ios/MullvadTypes/RelayConstraints.swift b/ios/MullvadTypes/RelayConstraints.swift index 21444a26586f..125d81b1c162 100644 --- a/ios/MullvadTypes/RelayConstraints.swift +++ b/ios/MullvadTypes/RelayConstraints.swift @@ -24,24 +24,31 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible @available(*, deprecated, renamed: "locations") private var location: RelayConstraint = .only(.country("se")) + // Added in 2024.1 + // Changed from RelayLocations to UserSelectedRelays in 2024.3 + @available(*, deprecated, renamed: "exitLocations") + private var locations: RelayConstraint = .only(UserSelectedRelays(locations: [.country("se")])) + + // Added in 2024.5 to support multi-hop + public var entryLocations: RelayConstraint + public var exitLocations: RelayConstraint + // Added in 2023.3 public var port: RelayConstraint public var filter: RelayConstraint - // Added in 2024.1 - // Changed from RelayLocations to UserSelectedRelays in 2024.3 - public var locations: RelayConstraint - public var debugDescription: String { - "RelayConstraints { locations: \(locations), port: \(port), filter: \(filter) }" + "RelayConstraints { entry locations: \(entryLocations), exit locations: \(exitLocations) , port: \(port), filter: \(filter) }" } public init( - locations: RelayConstraint = .only(UserSelectedRelays(locations: [.country("se")])), + entryLocations: RelayConstraint = .only(UserSelectedRelays(locations: [.country("se")])), + exitLocations: RelayConstraint = .only(UserSelectedRelays(locations: [.country("se")])), port: RelayConstraint = .any, filter: RelayConstraint = .any ) { - self.locations = locations + self.entryLocations = entryLocations + self.exitLocations = exitLocations self.port = port self.filter = filter } @@ -53,9 +60,19 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible port = try container.decodeIfPresent(RelayConstraint.self, forKey: .port) ?? .any filter = try container.decodeIfPresent(RelayConstraint.self, forKey: .filter) ?? .any - // Added in 2024.1 - locations = try container.decodeIfPresent(RelayConstraint.self, forKey: .locations) - ?? Self.migrateRelayLocation(decoder: decoder) + // Added in 2024.5 + entryLocations = try container.decodeIfPresent( + RelayConstraint.self, + forKey: .entryLocations + ) ?? .only(UserSelectedRelays(locations: [.country("se")])) + + exitLocations = try container + .decodeIfPresent(RelayConstraint.self, forKey: .exitLocations) ?? + container.decodeIfPresent( + RelayConstraint.self, + forKey: .locations + ) ?? + Self.migrateRelayLocation(decoder: decoder) ?? .only(UserSelectedRelays(locations: [.country("se")])) } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 8e0a79894885..dd0c621a6956 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -843,6 +843,7 @@ E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; }; E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */; }; F006CCFC2B99CC8400C6C2AC /* EditLocationsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */; }; + F01528BB2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01528BA2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift */; }; F0164EBA2B4456D30020268D /* AccessMethodRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */; }; F0164EBC2B482E430020268D /* AppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBB2B482E430020268D /* AppStorage.swift */; }; F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; }; @@ -873,6 +874,8 @@ F06045E62B231EB700B2D37A /* URLSessionTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06045E52B231EB700B2D37A /* URLSessionTransport.swift */; }; F06045EA2B23217E00B2D37A /* ShadowsocksTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */; }; F06045EC2B2322A500B2D37A /* Jittered.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06045EB2B2322A500B2D37A /* Jittered.swift */; }; + F072D3CF2C07122400906F64 /* MultihopUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F072D3CE2C07122400906F64 /* MultihopUpdaterTests.swift */; }; + F072D3D22C071AD100906F64 /* ShadowsocksLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F072D3D12C071AD100906F64 /* ShadowsocksLoaderTests.swift */; }; F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */; }; F07C9D952B220C77006F1C5E /* libshadowsocks_proxy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 01F1FF1D29F0627D007083C3 /* libshadowsocks_proxy.a */; }; F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; }; @@ -908,6 +911,10 @@ F0ACE3362BE517D6006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; }; F0ACE3372BE517F1006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; }; F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; + F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; + F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; + F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; }; + F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */; }; F0BE65372B9F136A005CC385 /* LocationSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */; }; F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; }; F0C3333C2B31A29C00D1A478 /* MullvadSettings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58B2FDD32AA71D2A003EB5C6 /* MullvadSettings.framework */; }; @@ -928,6 +935,8 @@ F0DDE42B2B220A15006B57A7 /* RelaySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DDE4282B220A15006B57A7 /* RelaySelector.swift */; }; F0DDE42C2B220A15006B57A7 /* Midpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DDE4292B220A15006B57A7 /* Midpoint.swift */; }; F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */; }; + F0E61CAA2BF2911D000C4A95 /* TunnelSettingsV5.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E61CA82BF2911D000C4A95 /* TunnelSettingsV5.swift */; }; + F0E61CAB2BF2911D000C4A95 /* MultihopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E61CA92BF2911D000C4A95 /* MultihopSettings.swift */; }; F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */; }; F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */; }; F0E8CC0C2A4EE672007ED3B4 /* SetupAccountCompletedController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedController.swift */; }; @@ -938,6 +947,8 @@ F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */; }; F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EF50D22A8FA47E0031E8DF /* ChangeLogInteractor.swift */; }; F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */; }; + F0F316192BF3572B0078DBCF /* RelaySelectorResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */; }; + F0F3161B2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */; }; F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; }; F0FADDEB2BE90AAE000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; }; F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; }; @@ -2081,6 +2092,7 @@ E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = ""; }; E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = ""; }; F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationsCoordinator.swift; sourceTree = ""; }; + F01528BA2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksRelaySelector.swift; sourceTree = ""; }; F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryStub.swift; sourceTree = ""; }; F0164EBB2B482E430020268D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = ""; }; @@ -2107,6 +2119,8 @@ F06045E52B231EB700B2D37A /* URLSessionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTransport.swift; sourceTree = ""; }; F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksTransport.swift; sourceTree = ""; }; F06045EB2B2322A500B2D37A /* Jittered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jittered.swift; sourceTree = ""; }; + F072D3CE2C07122400906F64 /* MultihopUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopUpdaterTests.swift; sourceTree = ""; }; + F072D3D12C071AD100906F64 /* ShadowsocksLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderTests.swift; sourceTree = ""; }; F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputTextFormatterTests.swift; sourceTree = ""; }; F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherOperation.swift; sourceTree = ""; }; F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredDeviceInAppNotificationProvider.swift; sourceTree = ""; }; @@ -2125,6 +2139,10 @@ F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = ""; }; F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = ""; }; F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; + F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; + F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; + F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = ""; }; + F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Shadowsocks.swift"; sourceTree = ""; }; F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderView.swift; sourceTree = ""; }; F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewConfiguration.swift; sourceTree = ""; }; @@ -2141,6 +2159,8 @@ F0DDE4282B220A15006B57A7 /* RelaySelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelaySelector.swift; sourceTree = ""; }; F0DDE4292B220A15006B57A7 /* Midpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Midpoint.swift; sourceTree = ""; }; F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeContentView.swift; sourceTree = ""; }; + F0E61CA82BF2911D000C4A95 /* TunnelSettingsV5.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV5.swift; sourceTree = ""; }; + F0E61CA92BF2911D000C4A95 /* MultihopSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultihopSettings.swift; sourceTree = ""; }; F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedContentView.swift; sourceTree = ""; }; F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedController.swift; sourceTree = ""; }; @@ -2152,6 +2172,8 @@ F0EF50D22A8FA47E0031E8DF /* ChangeLogInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangeLogInteractor.swift; sourceTree = ""; }; F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogViewModel.swift; sourceTree = ""; }; F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchArguments.swift; sourceTree = ""; }; + F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorResult.swift; sourceTree = ""; }; + F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoRelaysSatisfyingConstraintsError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2413,6 +2435,7 @@ 440E9EF32BDA942E00B1FD11 /* MullvadREST */ = { isa = PBXGroup; children = ( + F072D3D02C071A9100906F64 /* Shadowsocks */, 440E9EF42BDA943B00B1FD11 /* ApiHandlers */, 440E9EF52BDA954000B1FD11 /* Relay */, ); @@ -2560,6 +2583,7 @@ 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */, 7A516C3B2B712F0B00BBD33D /* IPOverrideWrapperTests.swift */, A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, + F072D3CE2C07122400906F64 /* MultihopUpdaterTests.swift */, 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */, ); path = MullvadSettings; @@ -2649,13 +2673,13 @@ 5823FA5326CE49F600283BF8 /* TunnelObserver.swift */, 58B93A1226C3F13600A55733 /* TunnelState.swift */, 44BB5F962BE527F4002520EB /* TunnelState+UI.swift */, + A9E0317D2ACC32920095D843 /* TunnelStatusBlockObserver.swift */, 5803B4B12940A48700C23744 /* TunnelStore.swift */, + A9E031762ACB08950095D843 /* UIApplication+Extensions.swift */, 5842102F282D8A3C00F24E46 /* UpdateAccountDataOperation.swift */, 58421031282E42B000F24E46 /* UpdateDeviceDataOperation.swift */, A9F360332AAB626300F53531 /* VPNConnectionProtocol.swift */, 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */, - A9E031762ACB08950095D843 /* UIApplication+Extensions.swift */, - A9E0317D2ACC32920095D843 /* TunnelStatusBlockObserver.swift */, ); path = TunnelManager; sourceTree = ""; @@ -3300,6 +3324,7 @@ 068CE5732927B7A400A068BB /* Migration.swift */, A9D96B192A8247C100A5C673 /* MigrationManager.swift */, 58B2FDD52AA71D2A003EB5C6 /* MullvadSettings.h */, + F0E61CA92BF2911D000C4A95 /* MultihopSettings.swift */, 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */, 44DD7D2C2B74E44A0005F67F /* QuantumResistanceSettings.swift */, 58FF2C02281BDE02009EF542 /* SettingsManager.swift */, @@ -3310,11 +3335,12 @@ A92ECC272A7802AB0052F1B1 /* StoredDeviceData.swift */, A97D30162AE6B5E90045C0E4 /* StoredWgKeyData.swift */, A92ECC202A77FFAF0052F1B1 /* TunnelSettings.swift */, + 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */, 587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */, 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */, A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */, A93181A02B727ED700E341D2 /* TunnelSettingsV4.swift */, - 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */, + F0E61CA82BF2911D000C4A95 /* TunnelSettingsV5.swift */, A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */, ); path = MullvadSettings; @@ -4037,6 +4063,14 @@ path = ApiHandlers; sourceTree = ""; }; + F072D3D02C071A9100906F64 /* Shadowsocks */ = { + isa = PBXGroup; + children = ( + F072D3D12C071AD100906F64 /* ShadowsocksLoaderTests.swift */, + ); + path = Shadowsocks; + sourceTree = ""; + }; F09D04B82AE94F27003D4F89 /* GeneralAPIs */ = { isa = PBXGroup; children = ( @@ -4086,8 +4120,14 @@ F0DDE4272B220A15006B57A7 /* Haversine.swift */, 7A516C392B7111A700BBD33D /* IPOverrideWrapper.swift */, F0DDE4292B220A15006B57A7 /* Midpoint.swift */, + F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */, 5820675A26E6576800655B05 /* RelayCache.swift */, F0DDE4282B220A15006B57A7 /* RelaySelector.swift */, + F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */, + F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */, + F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */, + F0B894F02BF751E300817A42 /* RelayWithDistance.swift */, + F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */, ); path = Relay; sourceTree = ""; @@ -4126,6 +4166,7 @@ F0DDE4102B220458006B57A7 /* ShadowsocksConfigurationCache.swift */, F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */, F0DDE40F2B220458006B57A7 /* ShadowSocksProxy.swift */, + F01528BA2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift */, F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */, ); path = Shadowsocks; @@ -5169,8 +5210,10 @@ buildActionMask = 2147483647; files = ( F05F39982B21C73C006E60A7 /* CachedRelays.swift in Sources */, + F0F316192BF3572B0078DBCF /* RelaySelectorResult.swift in Sources */, F05F39972B21C735006E60A7 /* RelayCache.swift in Sources */, A932D9EF2B5ADD0700999395 /* ProxyConfigurationTransportProvider.swift in Sources */, + F01528BB2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift in Sources */, 06799AE728F98E4800ACD94E /* RESTURLSession.swift in Sources */, A90763B52B2857D50045ADF0 /* Socks5Constants.swift in Sources */, A90763BA2B2857D50045ADF0 /* Socks5Error.swift in Sources */, @@ -5179,6 +5222,7 @@ A90763BD2B2857D50045ADF0 /* Socks5Connection.swift in Sources */, 06799AEC28F98E4800ACD94E /* RESTTaskIdentifier.swift in Sources */, 58E7BA192A975DF70068EC3A /* RESTTransportProvider.swift in Sources */, + F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */, 06799ADE28F98E4800ACD94E /* RESTRequestHandler.swift in Sources */, F0DDE4162B220458006B57A7 /* TransportProvider.swift in Sources */, 06799AEF28F98E4800ACD94E /* RetryStrategy.swift in Sources */, @@ -5203,12 +5247,15 @@ 06799AF228F98E4800ACD94E /* RESTAccessTokenManager.swift in Sources */, A90763B12B2857D50045ADF0 /* Socks5Endpoint.swift in Sources */, 06799AF328F98E4800ACD94E /* RESTAuthenticationProxy.swift in Sources */, + F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */, 7A516C3A2B7111A700BBD33D /* IPOverrideWrapper.swift in Sources */, F0DDE4142B220458006B57A7 /* ShadowSocksProxy.swift in Sources */, A90763B62B2857D50045ADF0 /* Socks5ConnectNegotiation.swift in Sources */, F06045E62B231EB700B2D37A /* URLSessionTransport.swift in Sources */, 06799AE628F98E4800ACD94E /* ServerRelaysResponse.swift in Sources */, F0DDE42B2B220A15006B57A7 /* RelaySelector.swift in Sources */, + F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */, + F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */, F0DDE42C2B220A15006B57A7 /* Midpoint.swift in Sources */, A90763C72B2858DC0045ADF0 /* CancellableChain.swift in Sources */, 06799AF128F98E4800ACD94E /* RESTAPIProxy.swift in Sources */, @@ -5219,6 +5266,7 @@ F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */, A9D99B9A2A1F7C3200DE27D3 /* RESTTransport.swift in Sources */, A90763BB2B2857D50045ADF0 /* Socks5AddressType.swift in Sources */, + F0F3161B2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift in Sources */, 06799AE028F98E4800ACD94E /* RESTCoding.swift in Sources */, A90763B72B2857D50045ADF0 /* Socks5DataStreamHandler.swift in Sources */, A90763B22B2857D50045ADF0 /* Socks5EndpointReader.swift in Sources */, @@ -5317,6 +5365,7 @@ F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */, 7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */, 7ABFB09E2BA316220074A49E /* RelayConstraintsTests.swift in Sources */, + F072D3D22C071AD100906F64 /* ShadowsocksLoaderTests.swift in Sources */, F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */, @@ -5357,6 +5406,7 @@ A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, + F072D3CF2C07122400906F64 /* MultihopUpdaterTests.swift in Sources */, F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */, 58BE4B9D2B18A85B007EA1D3 /* NSAttributedString+Markdown.swift in Sources */, A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */, @@ -5422,6 +5472,7 @@ buildActionMask = 2147483647; files = ( F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */, + F0E61CAA2BF2911D000C4A95 /* TunnelSettingsV5.swift in Sources */, 7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */, 58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */, F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */, @@ -5452,6 +5503,7 @@ 58B2FDE22AA71D5C003EB5C6 /* StoredAccountData.swift in Sources */, F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */, 7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */, + F0E61CAB2BF2911D000C4A95 /* MultihopSettings.swift in Sources */, 58B2FDE82AA71D5C003EB5C6 /* KeychainSettingsStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index b31acdcaab3d..1956e789e812 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -40,7 +40,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private(set) var relayCacheTracker: RelayCacheTracker! private(set) var storePaymentManager: StorePaymentManager! private var transportMonitor: TransportMonitor! - private var relayConstraintsObserver: TunnelBlockObserver! + private var settingsObserver: TunnelBlockObserver! private let migrationManager = MigrationManager() private(set) var accessMethodRepository = AccessMethodRepository() @@ -90,10 +90,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD tunnelManager = createTunnelManager(application: application) let constraintsUpdater = RelayConstraintsUpdater() - relayConstraintsObserver = TunnelBlockObserver(didUpdateTunnelSettings: { _, settings in + let multihopListener = MultihopStateListener() + let multihopUpdater = MultihopUpdater(listener: multihopListener) + + settingsObserver = TunnelBlockObserver(didUpdateTunnelSettings: { _, settings in + multihopListener.onNewMultihop?(settings.tunnelMultihopState) constraintsUpdater.onNewConstraints?(settings.relayConstraints) }) - tunnelManager.addObserver(relayConstraintsObserver) + tunnelManager.addObserver(settingsObserver) storePaymentManager = StorePaymentManager( backgroundTaskProvider: application, @@ -102,13 +106,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD accountsProxy: accountsProxy, transactionLog: .default ) - let urlSessionTransport = URLSessionTransport(urlSession: REST.makeURLSession()) let shadowsocksCache = ShadowsocksConfigurationCache(cacheDirectory: containerURL) + let shadowsocksRelaySelector = ShadowsocksRelaySelector( + relayCache: ipOverrideWrapper, + multihopUpdater: multihopUpdater + ) shadowsocksLoader = ShadowsocksLoader( - shadowsocksCache: shadowsocksCache, - relayCache: ipOverrideWrapper, + cache: shadowsocksCache, + relaySelector: shadowsocksRelaySelector, constraintsUpdater: constraintsUpdater ) diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift index 713458e5b540..ff69c6288788 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift @@ -78,29 +78,30 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting { private func updateRelayConstraints(for action: EditCustomListCoordinator.FinishAction, in list: CustomList) { var relayConstraints = tunnelManager.settings.relayConstraints - guard let customListSelection = relayConstraints.locations.value?.customListSelection, + guard let customListSelection = relayConstraints.exitLocations.value?.customListSelection, customListSelection.listId == list.id else { return } switch action { case .save: + // TODO: - Add entry locations if customListSelection.isList { let selectedRelays = UserSelectedRelays( locations: list.locations, customListSelection: UserSelectedRelays.CustomListSelection(listId: list.id, isList: true) ) - relayConstraints.locations = .only(selectedRelays) + relayConstraints.exitLocations = .only(selectedRelays) } else { let selectedConstraintIsRemovedFromList = list.locations.filter { - relayConstraints.locations.value?.locations.contains($0) ?? false + relayConstraints.exitLocations.value?.locations.contains($0) ?? false }.isEmpty if selectedConstraintIsRemovedFromList { - relayConstraints.locations = .only(UserSelectedRelays(locations: [])) + relayConstraints.exitLocations = .only(UserSelectedRelays(locations: [])) } } case .delete: - relayConstraints.locations = .only(UserSelectedRelays(locations: [])) + relayConstraints.exitLocations = .only(UserSelectedRelays(locations: [])) } tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 58e828ef1289..38b9ced8cd22 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -54,9 +54,10 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { } func start() { + // TODO: - the location should be defined whether it's Entry or Exit location let locationViewControllerWrapper = LocationViewControllerWrapper( customListRepository: customListRepository, - selectedRelays: tunnelManager.settings.relayConstraints.locations.value + selectedRelays: tunnelManager.settings.relayConstraints.exitLocations.value ) locationViewControllerWrapper.delegate = self @@ -156,7 +157,7 @@ extension LocationCoordinator: RelayCacheTrackerObserver { extension LocationCoordinator: LocationViewControllerWrapperDelegate { func didSelectRelays(relays: UserSelectedRelays) { var relayConstraints = tunnelManager.settings.relayConstraints - relayConstraints.locations = .only(relays) + relayConstraints.exitLocations = .only(relays) tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { self.tunnelManager.startTunnel() diff --git a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift index ea5260b8af2d..e53c5a50b214 100644 --- a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift +++ b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift @@ -159,9 +159,9 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate { private func pickRelay() throws -> SelectedRelay { let cachedRelays = try relayCacheTracker.getCachedRelays() let tunnelSettings = try SettingsManager.readSettings() - let selectorResult = try RelaySelector.evaluate( - relays: cachedRelays.relays, - constraints: tunnelSettings.relayConstraints, + let selectorResult = try RelaySelector.WireGuard.evaluate( + by: tunnelSettings.relayConstraints, + in: cachedRelays.relays, numberOfFailedAttempts: 0 ) return SelectedRelay( diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index f16281019912..bced82fd46db 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -782,9 +782,9 @@ final class TunnelManager: StorePaymentObserver { fileprivate func selectRelay() throws -> SelectedRelay { let cachedRelays = try relayCacheTracker.getCachedRelays() let retryAttempts = tunnelStatus.observedState.connectionState?.connectionAttemptCount ?? 0 - let selectorResult = try RelaySelector.evaluate( - relays: cachedRelays.relays, - constraints: settings.relayConstraints, + let selectorResult = try RelaySelector.WireGuard.evaluate( + by: settings.relayConstraints, + in: cachedRelays.relays, numberOfFailedAttempts: retryAttempts ) diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift index 68f48c011289..9163613bbe5e 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift @@ -17,14 +17,16 @@ private let defaultPort: UInt16 = 53 class RelaySelectorTests: XCTestCase { let sampleRelays = ServerRelaysResponseStubs.sampleRelays + // MARK: - single-Hop tests + func testCountryConstraint() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.country("es")])) + exitLocations: .only(UserSelectedRelays(locations: [.country("es")])) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -33,26 +35,25 @@ class RelaySelectorTests: XCTestCase { func testCityConstraint() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.city("se", "got")])) + exitLocations: .only(UserSelectedRelays(locations: [.city("se", "got")])) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) - XCTAssertEqual(result.relay.hostname, "se10-wireguard") } func testHostnameConstraint() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -61,7 +62,7 @@ class RelaySelectorTests: XCTestCase { func testMultipleLocationsConstraint() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [ + exitLocations: .only(UserSelectedRelays(locations: [ .city("se", "got"), .hostname("se", "sto", "se6-wireguard"), ])) @@ -84,13 +85,19 @@ class RelaySelectorTests: XCTestCase { ) } - let constrainedLocations = RelaySelector.applyConstraints(constraints, relays: relayWithLocations) + let constrainedLocations = RelaySelector.applyConstraints( + constraints.exitLocations, + portConstraint: constraints.port, + filterConstraint: constraints.filter, + relays: relayWithLocations + ) XCTAssertTrue( constrainedLocations.contains( where: { $0.matches(location: .city("se", "got")) } ) ) + XCTAssertTrue( constrainedLocations.contains( where: { $0.matches(location: .hostname("se", "sto", "se6-wireguard")) } @@ -100,13 +107,13 @@ class RelaySelectorTests: XCTestCase { func testSpecificPortConstraint() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), port: .only(1) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -115,47 +122,70 @@ class RelaySelectorTests: XCTestCase { func testRandomPortSelectionWithFailedAttempts() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) ) let allPorts = portRanges.flatMap { $0 } - var result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + var result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) XCTAssertTrue(allPorts.contains(result.endpoint.ipv4Relay.port)) - result = try RelaySelector.evaluate(relays: sampleRelays, constraints: constraints, numberOfFailedAttempts: 1) + result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, + numberOfFailedAttempts: 1 + ) XCTAssertTrue(allPorts.contains(result.endpoint.ipv4Relay.port)) - result = try RelaySelector.evaluate(relays: sampleRelays, constraints: constraints, numberOfFailedAttempts: 2) + result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, + numberOfFailedAttempts: 2 + ) XCTAssertEqual(result.endpoint.ipv4Relay.port, defaultPort) - result = try RelaySelector.evaluate(relays: sampleRelays, constraints: constraints, numberOfFailedAttempts: 3) + result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, + numberOfFailedAttempts: 3 + ) XCTAssertEqual(result.endpoint.ipv4Relay.port, defaultPort) - result = try RelaySelector.evaluate(relays: sampleRelays, constraints: constraints, numberOfFailedAttempts: 4) + result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, + numberOfFailedAttempts: 4 + ) XCTAssertTrue(allPorts.contains(result.endpoint.ipv4Relay.port)) } func testClosestShadowsocksRelay() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.city("se", "sto")])) + exitLocations: .only(UserSelectedRelays(locations: [.city("se", "sto")])) ) - let selectedRelay = RelaySelector.closestShadowsocksRelayConstrained(by: constraints, in: sampleRelays) + let selectedRelay = RelaySelector.Shadowsocks.closestRelay( + location: constraints.exitLocations, + port: constraints.port, + filter: constraints.filter, + in: sampleRelays + ) XCTAssertEqual(selectedRelay?.hostname, "se-sto-br-001") } func testClosestShadowsocksRelayIsRandomWhenNoContraintsAreSatisfied() throws { let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.country("INVALID COUNTRY")])) + exitLocations: .only(UserSelectedRelays(locations: [.country("INVALID COUNTRY")])) ) - let selectedRelay = try XCTUnwrap(RelaySelector.closestShadowsocksRelayConstrained( - by: constraints, + let selectedRelay = try XCTUnwrap(RelaySelector.Shadowsocks.closestRelay( + location: constraints.exitLocations, + port: constraints.port, + filter: constraints.filter, in: sampleRelays )) @@ -166,13 +196,13 @@ class RelaySelectorTests: XCTestCase { let filter = RelayFilter(ownership: .owned, providers: .any) let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), filter: .only(filter) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -183,13 +213,13 @@ class RelaySelectorTests: XCTestCase { let filter = RelayFilter(ownership: .rented, providers: .any) let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), filter: .only(filter) ) - let result = try? RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try? RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -201,13 +231,13 @@ class RelaySelectorTests: XCTestCase { let filter = RelayFilter(ownership: .any, providers: .only([provider])) let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), filter: .only(filter) ) - let result = try RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) @@ -219,16 +249,18 @@ class RelaySelectorTests: XCTestCase { let filter = RelayFilter(ownership: .any, providers: .only([provider])) let constraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), filter: .only(filter) ) - let result = try? RelaySelector.evaluate( - relays: sampleRelays, - constraints: constraints, + let result = try? RelaySelector.WireGuard.evaluate( + by: constraints, + in: sampleRelays, numberOfFailedAttempts: 0 ) XCTAssertNil(result) } + + // MARK: - Multi-Hop tests } diff --git a/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift b/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift new file mode 100644 index 000000000000..58867783d074 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift @@ -0,0 +1,145 @@ +// +// ShadowsocksLoaderTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2024-05-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadREST +@testable import MullvadSettings +@testable import MullvadTypes + +import XCTest + +class ShadowsocksLoaderTests: XCTestCase { + private let sampleRelays = ServerRelaysResponseStubs.sampleRelays + + private var relayConstraintsUpdater: RelayConstraintsUpdater! + private var shadowsocksConfigurationCache: ShadowsocksConfigurationCacheStub! + private var relaySelector: ShadowsocksRelaySelectorStub! + private var shadowsocksLoader: ShadowsocksLoader! + private var relayConstraints = RelayConstraints() + + override func setUpWithError() throws { + relayConstraintsUpdater = RelayConstraintsUpdater() + shadowsocksConfigurationCache = ShadowsocksConfigurationCacheStub() + relaySelector = ShadowsocksRelaySelectorStub(relays: sampleRelays) + + shadowsocksLoader = ShadowsocksLoader( + cache: shadowsocksConfigurationCache, + relaySelector: relaySelector, + constraintsUpdater: relayConstraintsUpdater + ) + } + + func testLoadConfigWithMultihopDisabled() throws { + relaySelector.multihopState = .off + relaySelector.exitBridgeResult = .success(try XCTUnwrap(closetRelayTo( + location: relayConstraints.exitLocations, + port: relayConstraints.port, + filter: relayConstraints.filter, + in: sampleRelays + ))) + relaySelector.entryBridgeResult = .failure(ShadowsocksRelaySelectorStubError()) + + let configuration = try XCTUnwrap(shadowsocksLoader.load()) + XCTAssertEqual(configuration, try XCTUnwrap(shadowsocksConfigurationCache.read())) + } + + func testLoadConfigWithMultihopEnabled() throws { + relaySelector.multihopState = .on + relaySelector.entryBridgeResult = .success(try XCTUnwrap(closetRelayTo( + location: relayConstraints.entryLocations, + port: relayConstraints.port, + filter: relayConstraints.filter, + in: sampleRelays + ))) + relaySelector.exitBridgeResult = .failure(ShadowsocksRelaySelectorStubError()) + + let configuration = try XCTUnwrap(shadowsocksLoader.load()) + XCTAssertEqual(configuration, try XCTUnwrap(shadowsocksConfigurationCache.read())) + } + + func testConstraintsUpdateClearsCache() throws { + relaySelector.exitBridgeResult = .success(try XCTUnwrap(closetRelayTo( + location: relayConstraints.exitLocations, + port: relayConstraints.port, + filter: relayConstraints.filter, + in: sampleRelays + ))) + relaySelector.entryBridgeResult = .failure(ShadowsocksRelaySelectorStubError()) + + relayConstraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.city("ca", "tor")])), + exitLocations: .only(UserSelectedRelays(locations: [.country("ae")])) + ) + + relayConstraintsUpdater.onNewConstraints?(relayConstraints) + + XCTAssertTrue(shadowsocksConfigurationCache.isCleared) + } + + private func closetRelayTo( + location: RelayConstraint, + port: RelayConstraint, + filter: RelayConstraint, + in: REST.ServerRelaysResponse + ) -> REST.BridgeRelay? { + RelaySelector.Shadowsocks.closestRelay( + location: location, + port: port, + filter: filter, + in: sampleRelays + ) + } +} + +private class ShadowsocksRelaySelectorStub: ShadowsocksRelaySelectorProtocol { + var entryBridgeResult: Result = .failure(ShadowsocksRelaySelectorStubError()) + var exitBridgeResult: Result = .failure(ShadowsocksRelaySelectorStubError()) + var multihopState: MultihopState = .off + + private let relays: REST.ServerRelaysResponse + + init(relays: REST.ServerRelaysResponse) { + self.relays = relays + } + + func selectRelay(with constraints: RelayConstraints) throws -> REST.BridgeRelay? { + switch multihopState { + case .on: + try entryBridgeResult.get() + case .off: + try exitBridgeResult.get() + } + } + + func getBridges() throws -> REST.ServerShadowsocks? { + RelaySelector.Shadowsocks.tcpBridge(from: relays) + } +} + +private class ShadowsocksConfigurationCacheStub: ShadowsocksConfigurationCacheProtocol { + private var cachedConfiguration: ShadowsocksConfiguration? + private(set) var isCleared = false + + func read() throws -> ShadowsocksConfiguration { + guard let cachedConfiguration else { + throw ShadowsocksConfigurationCacheStubError() + } + return cachedConfiguration + } + + func write(_ configuration: ShadowsocksConfiguration) throws { + self.cachedConfiguration = configuration + } + + func clear() throws { + self.cachedConfiguration = nil + self.isCleared = true + } +} + +private struct ShadowsocksRelaySelectorStubError: Error {} +private struct ShadowsocksConfigurationCacheStubError: Error {} diff --git a/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift b/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift index ace14e8eb0a6..22be6900f2cb 100644 --- a/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift +++ b/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift @@ -119,10 +119,49 @@ final class MigrationManagerTests: XCTestCase { wait(for: [failedMigrationExpectation], timeout: 1) } + func testSuccessfulMigrationFromV4ToLatest() throws { + var settingsV4 = TunnelSettingsV4() + let relayConstraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) + ) + + settingsV4.relayConstraints = relayConstraints + settingsV4.tunnelQuantumResistance = .off + settingsV4.wireGuardObfuscation = WireGuardObfuscationSettings(state: .off, port: .automatic) + + try migrateToLatest(settingsV4, version: .v4) + + // Once the migration is done, settings should have been updated to the latest available version + // Verify that the old settings are still valid + let latestSettings = try SettingsManager.readSettings() + XCTAssertEqual(settingsV4.relayConstraints, latestSettings.relayConstraints) + XCTAssertEqual(settingsV4.tunnelQuantumResistance, latestSettings.tunnelQuantumResistance) + XCTAssertEqual(settingsV4.wireGuardObfuscation, latestSettings.wireGuardObfuscation) + } + + func testSuccessfulMigrationFromV3ToLatest() throws { + var settingsV3 = TunnelSettingsV3() + let relayConstraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) + ) + + settingsV3.relayConstraints = relayConstraints + settingsV3.dnsSettings = DNSSettings() + settingsV3.wireGuardObfuscation = WireGuardObfuscationSettings(state: .on, port: .port80) + + try migrateToLatest(settingsV3, version: .v3) + + // Once the migration is done, settings should have been updated to the latest available version + // Verify that the old settings are still valid + let latestSettings = try SettingsManager.readSettings() + XCTAssertEqual(settingsV3.relayConstraints, latestSettings.relayConstraints) + XCTAssertEqual(settingsV3.wireGuardObfuscation, latestSettings.wireGuardObfuscation) + } + func testSuccessfulMigrationFromV2ToLatest() throws { var settingsV2 = TunnelSettingsV2() let osakaRelayConstraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) + exitLocations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) ) settingsV2.relayConstraints = osakaRelayConstraints @@ -136,7 +175,7 @@ final class MigrationManagerTests: XCTestCase { func testSuccessfulMigrationFromV1ToLatest() throws { var settingsV1 = TunnelSettingsV1() let osakaRelayConstraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) + exitLocations: .only(UserSelectedRelays(locations: [.city("jp", "osa")])) ) settingsV1.relayConstraints = osakaRelayConstraints diff --git a/ios/MullvadVPNTests/MullvadSettings/MultihopUpdaterTests.swift b/ios/MullvadVPNTests/MullvadSettings/MultihopUpdaterTests.swift new file mode 100644 index 000000000000..76805c918398 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadSettings/MultihopUpdaterTests.swift @@ -0,0 +1,31 @@ +// +// MultihopUpdaterTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2024-05-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadSettings +import XCTest + +class MultihopUpdaterTests: XCTestCase { + func testMultipleListener() { + let multihopStateListener = MultihopStateListener() + let multihopUpdater = MultihopUpdater(listener: multihopStateListener) + + var count = 0 + + multihopUpdater.addObserver(MultihopObserverBlock(didUpdateMultihop: { _, _ in + count += 1 + })) + + multihopUpdater.addObserver(MultihopObserverBlock(didUpdateMultihop: { _, _ in + count += 1 + })) + + multihopStateListener.onNewMultihop?(.on) + + XCTAssertEqual(count, 2) + } +} diff --git a/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift b/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift index 89a234f3ce9a..9ebcee9d4097 100644 --- a/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift +++ b/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift @@ -48,7 +48,7 @@ final class TunnelSettingsUpdateTests: XCTestCase { // When: let relayConstraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.country("zz")])), + exitLocations: .only(UserSelectedRelays(locations: [.country("zz")])), port: .only(9999), filter: .only(.init(ownership: .rented, providers: .only(["foo", "bar"]))) ) @@ -70,4 +70,16 @@ final class TunnelSettingsUpdateTests: XCTestCase { // Then: XCTAssertEqual(settings.tunnelQuantumResistance, .on) } + + func testApplyMultihop() { + // Given: + var settings = LatestTunnelSettings() + + // When: + let update = TunnelSettingsUpdate.multihop(.on) + update.apply(to: &settings) + + // Then: + XCTAssertEqual(settings.tunnelMultihopState, .on) + } } diff --git a/ios/MullvadVPNTests/MullvadTypes/MockFileCache.swift b/ios/MullvadVPNTests/MullvadTypes/MockFileCache.swift index 0b07c788e281..ea13a7356e0a 100644 --- a/ios/MullvadVPNTests/MullvadTypes/MockFileCache.swift +++ b/ios/MullvadVPNTests/MullvadTypes/MockFileCache.swift @@ -45,6 +45,13 @@ final class MockFileCache: FileCacheProtocol { state = .exists(content) } + func clear() throws { + stateLock.lock() + defer { stateLock.unlock() } + + state = .fileNotFound + } + enum State: Equatable { /// File does not exist yet. case fileNotFound diff --git a/ios/MullvadVPNTests/MullvadTypes/RelayConstraintsTests.swift b/ios/MullvadVPNTests/MullvadTypes/RelayConstraintsTests.swift index 401dc13eddb2..15ed22663e28 100644 --- a/ios/MullvadVPNTests/MullvadTypes/RelayConstraintsTests.swift +++ b/ios/MullvadVPNTests/MullvadTypes/RelayConstraintsTests.swift @@ -20,7 +20,7 @@ final class RelayConstraintsTests: XCTestCase { let constraintsFromJson = try parseData(from: constraintsV1) let constraintsFromInit = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.city("se", "got")])), + exitLocations: .only(UserSelectedRelays(locations: [.city("se", "got")])), port: .only(80), filter: .only(RelayFilter(ownership: .rented, providers: .any)) ) diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 6b27aa59f83d..03dabff3240f 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -21,6 +21,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private let internalQueue = DispatchQueue(label: "PacketTunnel-internalQueue") private let providerLogger: Logger private let constraintsUpdater = RelayConstraintsUpdater() + private let multihopStateListener = MultihopStateListener() + + private var multihopUpdater: MultihopUpdater + private let settingsReader = SettingsReader() private var actor: PacketTunnelActor! private var postQuantumActor: PostQuantumKeyExchangeActor! @@ -44,6 +48,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { ipOverrideRepository: IPOverrideRepository() ) + multihopUpdater = MultihopUpdater(listener: multihopStateListener) + super.init() let transportProvider = setUpTransportProvider( @@ -69,7 +75,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let devicesProxy = proxyFactory.createDevicesProxy() deviceChecker = DeviceChecker(accountsProxy: accountsProxy, devicesProxy: devicesProxy) - relaySelector = RelaySelectorWrapper(relayCache: ipOverrideWrapper) + relaySelector = RelaySelectorWrapper( + relayCache: ipOverrideWrapper, + multihopState: .off, + multihopUpdater: multihopUpdater + ) + + let multihopState = try? settingsReader.read().multihopState + + multihopStateListener.onNewMultihop?(multihopState ?? .off) actor = PacketTunnelActor( timings: PacketTunnelActorTimings(), @@ -78,7 +92,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { defaultPathObserver: PacketTunnelPathObserver(packetTunnelProvider: self, eventQueue: internalQueue), blockedStateErrorMapper: BlockedStateErrorMapper(), relaySelector: relaySelector, - settingsReader: SettingsReader(), + settingsReader: settingsReader, protocolObfuscator: ProtocolObfuscator() ) @@ -157,11 +171,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let urlSessionTransport = URLSessionTransport(urlSession: urlSession) let shadowsocksCache = ShadowsocksConfigurationCache(cacheDirectory: appContainerURL) + let multihopState = try? settingsReader.read().multihopState + + multihopStateListener.onNewMultihop?(multihopState ?? .off) + + let shadowsocksRelaySelector = ShadowsocksRelaySelector( + relayCache: ipOverrideWrapper, + multihopUpdater: multihopUpdater + ) + let transportStrategy = TransportStrategy( datasource: AccessMethodRepository(), shadowsocksLoader: ShadowsocksLoader( - shadowsocksCache: shadowsocksCache, - relayCache: ipOverrideWrapper, + cache: shadowsocksCache, + relaySelector: shadowsocksRelaySelector, constraintsUpdater: constraintsUpdater ) ) diff --git a/ios/PacketTunnel/PacketTunnelProvider/RelaySelectorWrapper.swift b/ios/PacketTunnel/PacketTunnelProvider/RelaySelectorWrapper.swift index 73f6fa267407..2b768039e4a4 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/RelaySelectorWrapper.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/RelaySelectorWrapper.swift @@ -8,27 +8,56 @@ import Foundation import MullvadREST +import MullvadSettings import MullvadTypes import PacketTunnelCore -struct RelaySelectorWrapper: RelaySelectorProtocol { +struct MultihopNotImplementedError: LocalizedError { + public var errorDescription: String? { + "Picking relays for Multihop is not implemented yet." + } +} + +final class RelaySelectorWrapper: RelaySelectorProtocol { let relayCache: RelayCacheProtocol + let multihopUpdater: MultihopUpdater + private var multihopState: MultihopState = .off + + public init( + relayCache: RelayCacheProtocol, + multihopState: MultihopState, + multihopUpdater: MultihopUpdater + ) { + self.relayCache = relayCache + self.multihopState = multihopState + self.multihopUpdater = multihopUpdater + + multihopUpdater.addObserver(MultihopObserverBlock(didUpdateMultihop: { [weak self] _, multihopState in + self?.multihopState = multihopState + })) + } func selectRelay( with constraints: RelayConstraints, connectionAttemptFailureCount: UInt ) throws -> SelectedRelay { - let selectorResult = try RelaySelector.evaluate( - relays: relayCache.read().relays, - constraints: constraints, - numberOfFailedAttempts: connectionAttemptFailureCount - ) - - return SelectedRelay( - endpoint: selectorResult.endpoint, - hostname: selectorResult.relay.hostname, - location: selectorResult.location, - retryAttempts: connectionAttemptFailureCount - ) + switch multihopState { + case .off: + let selectorResult = try RelaySelector.WireGuard.evaluate( + by: constraints, + in: relayCache.read().relays, + numberOfFailedAttempts: connectionAttemptFailureCount + ) + + return SelectedRelay( + endpoint: selectorResult.endpoint, + hostname: selectorResult.relay.hostname, + location: selectorResult.location, + retryAttempts: connectionAttemptFailureCount + ) + + case .on: + throw MultihopNotImplementedError() + } } } diff --git a/ios/PacketTunnel/PacketTunnelProvider/SettingsReader.swift b/ios/PacketTunnel/PacketTunnelProvider/SettingsReader.swift index ade3b5a3afd2..2de052e1bd69 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/SettingsReader.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/SettingsReader.swift @@ -22,7 +22,8 @@ struct SettingsReader: SettingsReaderProtocol { relayConstraints: settings.relayConstraints, dnsServers: settings.dnsSettings.selectedDNSServers, obfuscation: settings.wireGuardObfuscation, - quantumResistance: settings.tunnelQuantumResistance + quantumResistance: settings.tunnelQuantumResistance, + multihopState: settings.tunnelMultihopState ) } } diff --git a/ios/PacketTunnelCore/Actor/Protocols/SettingsReaderProtocol.swift b/ios/PacketTunnelCore/Actor/Protocols/SettingsReaderProtocol.swift index ffe7cdcc2ffe..05d60a23f2b2 100644 --- a/ios/PacketTunnelCore/Actor/Protocols/SettingsReaderProtocol.swift +++ b/ios/PacketTunnelCore/Actor/Protocols/SettingsReaderProtocol.swift @@ -42,13 +42,17 @@ public struct Settings { public var quantumResistance: TunnelQuantumResistance + /// Whether multi-hop is enabled. + public var multihopState: MultihopState + public init( privateKey: PrivateKey, interfaceAddresses: [IPAddressRange], relayConstraints: RelayConstraints, dnsServers: SelectedDNSServers, obfuscation: WireGuardObfuscationSettings, - quantumResistance: TunnelQuantumResistance + quantumResistance: TunnelQuantumResistance, + multihopState: MultihopState ) { self.privateKey = privateKey self.interfaceAddresses = interfaceAddresses @@ -56,6 +60,7 @@ public struct Settings { self.dnsServers = dnsServers self.obfuscation = obfuscation self.quantumResistance = quantumResistance + self.multihopState = multihopState } } diff --git a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift index cdd062b098f5..37664e68532f 100644 --- a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift +++ b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift @@ -79,11 +79,11 @@ final class AppMessageHandlerTests: XCTestCase { let appMessageHandler = createAppMessageHandler(actor: actor) let relayConstraints = RelayConstraints( - locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])) ) - let selectorResult = try XCTUnwrap(try? RelaySelector.evaluate( - relays: ServerRelaysResponseStubs.sampleRelays, - constraints: relayConstraints, + let selectorResult = try XCTUnwrap(try? RelaySelector.WireGuard.evaluate( + by: relayConstraints, + in: ServerRelaysResponseStubs.sampleRelays, numberOfFailedAttempts: 0 )) diff --git a/ios/PacketTunnelCoreTests/Mocks/SettingsReaderStub.swift b/ios/PacketTunnelCoreTests/Mocks/SettingsReaderStub.swift index edb9e99e6d40..f5806b9a47f2 100644 --- a/ios/PacketTunnelCoreTests/Mocks/SettingsReaderStub.swift +++ b/ios/PacketTunnelCoreTests/Mocks/SettingsReaderStub.swift @@ -30,7 +30,8 @@ extension SettingsReaderStub { relayConstraints: RelayConstraints(), dnsServers: .gateway, obfuscation: WireGuardObfuscationSettings(state: .off, port: .automatic), - quantumResistance: .automatic + quantumResistance: .automatic, + multihopState: .off ) return SettingsReaderStub { diff --git a/ios/PacketTunnelCoreTests/PacketTunnelActorTests.swift b/ios/PacketTunnelCoreTests/PacketTunnelActorTests.swift index 9fa8b90258fd..277854ea8fea 100644 --- a/ios/PacketTunnelCoreTests/PacketTunnelActorTests.swift +++ b/ios/PacketTunnelCoreTests/PacketTunnelActorTests.swift @@ -209,7 +209,8 @@ final class PacketTunnelActorTests: XCTestCase { relayConstraints: RelayConstraints(), dnsServers: .gateway, obfuscation: WireGuardObfuscationSettings(state: .off, port: .automatic), - quantumResistance: .automatic + quantumResistance: .automatic, + multihopState: .off ) } } diff --git a/ios/PacketTunnelCoreTests/ProtocolObfuscatorTests.swift b/ios/PacketTunnelCoreTests/ProtocolObfuscatorTests.swift index 21f30991bbf5..e81ae10bf606 100644 --- a/ios/PacketTunnelCoreTests/ProtocolObfuscatorTests.swift +++ b/ios/PacketTunnelCoreTests/ProtocolObfuscatorTests.swift @@ -111,7 +111,9 @@ final class ProtocolObfuscatorTests: XCTestCase { obfuscation: WireGuardObfuscationSettings( state: obfuscationState, port: obfuscationPort - ), quantumResistance: quantumResistance + ), + quantumResistance: quantumResistance, + multihopState: .off ) } }