From 5cd5d21bfab538e094d5bcfea2479c9be34b3171 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Thu, 8 Aug 2024 13:25:43 +0200 Subject: [PATCH] Allow relay selector to filter DAITA enabled relays --- .../MullvadREST/RelaySelectorStub.swift | 2 +- .../ApiHandlers/ServerRelaysResponse.swift | 5 +- ios/MullvadREST/Relay/AnyRelay.swift | 1 + .../Relay/MultihopDecisionFlow.swift | 8 +- .../NoRelaysSatisfyingConstraintsError.swift | 33 +++- ios/MullvadREST/Relay/RelayPicking.swift | 38 ++++- .../Relay/RelaySelector+Shadowsocks.swift | 5 +- .../Relay/RelaySelector+Wireguard.swift | 15 +- ios/MullvadREST/Relay/RelaySelector.swift | 147 +++++++++++++----- .../Relay/RelaySelectorWrapper.swift | 2 + .../Coordinators/LocationCoordinator.swift | 2 +- .../TunnelStatusNotificationProvider.swift | 6 + .../ServerRelaysResponse+Stubs.swift | 31 +++- .../Relay/MultihopDecisionFlowTests.swift | 2 + .../MullvadREST/Relay/RelayPickingTests.swift | 11 +- .../Relay/RelaySelectorTests.swift | 106 ++++++++++++- .../Relay/RelaySelectorWrapperTests.swift | 86 ++++++++++ .../IPOverrideWrapperTests.swift | 3 +- .../BlockedStateErrorMapper.swift | 15 +- .../Actor/State+Extensions.swift | 4 +- ios/PacketTunnelCore/Actor/State.swift | 11 +- .../AppMessageHandlerTests.swift | 3 +- ...ultiHopPostQuantumKeyExchangingTests.swift | 6 +- ...ostQuantumKeyExchangingPipelineTests.swift | 6 +- ...ngleHopPostQuantumKeyExchangingTests.swift | 3 +- 25 files changed, 459 insertions(+), 92 deletions(-) diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift index 7dd95ae97621..8fc6ba91415e 100644 --- a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift +++ b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift @@ -58,7 +58,7 @@ extension RelaySelectorStub { /// Returns a relay selector that cannot satisfy constraints . public static func unsatisfied() -> RelaySelectorStub { return RelaySelectorStub { _ in - throw NoRelaysSatisfyingConstraintsError() + throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) } } } diff --git a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift index a575da03cc06..e38ae3e23ea2 100644 --- a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift +++ b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift @@ -34,6 +34,7 @@ extension REST { public let ipv4AddrIn: IPv4Address public let weight: UInt64 public let includeInCountry: Bool + public var daita: Bool? public func override(ipv4AddrIn: IPv4Address?) -> Self { return BridgeRelay( @@ -60,6 +61,7 @@ extension REST { public let ipv6AddrIn: IPv6Address public let publicKey: Data public let includeInCountry: Bool + public let daita: Bool? public func override(ipv4AddrIn: IPv4Address?, ipv6AddrIn: IPv6Address?) -> Self { return ServerRelay( @@ -72,7 +74,8 @@ extension REST { ipv4AddrIn: ipv4AddrIn ?? self.ipv4AddrIn, ipv6AddrIn: ipv6AddrIn ?? self.ipv6AddrIn, publicKey: publicKey, - includeInCountry: includeInCountry + includeInCountry: includeInCountry, + daita: daita ) } } diff --git a/ios/MullvadREST/Relay/AnyRelay.swift b/ios/MullvadREST/Relay/AnyRelay.swift index 6c3c49aa5596..13f10029b2af 100644 --- a/ios/MullvadREST/Relay/AnyRelay.swift +++ b/ios/MullvadREST/Relay/AnyRelay.swift @@ -17,6 +17,7 @@ public protocol AnyRelay { var weight: UInt64 { get } var active: Bool { get } var includeInCountry: Bool { get } + var daita: Bool? { get } func override(ipv4AddrIn: IPv4Address?, ipv6AddrIn: IPv6Address?) -> Self } diff --git a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift index 53d8d1a8bc9d..610cdbb5e3e6 100644 --- a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift +++ b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift @@ -26,13 +26,13 @@ struct OneToOne: MultihopDecisionFlow { func pick(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) throws -> SelectedRelays { guard canHandle(entryCandidates: entryCandidates, exitCandidates: exitCandidates) else { guard let next else { - throw NoRelaysSatisfyingConstraintsError() + throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) } guard entryCandidates.first != exitCandidates.first else { - throw NoRelaysSatisfyingConstraintsError() + throw NoRelaysSatisfyingConstraintsError(.entryEqualsExit) } let entryMatch = try relayPicker.findBestMatch(from: entryCandidates) @@ -61,7 +61,7 @@ struct OneToMany: MultihopDecisionFlow { guard canHandle(entryCandidates: entryCandidates, exitCandidates: exitCandidates) else { guard let next else { - throw NoRelaysSatisfyingConstraintsError() + throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) } @@ -100,7 +100,7 @@ struct ManyToMany: MultihopDecisionFlow { guard canHandle(entryCandidates: entryCandidates, exitCandidates: exitCandidates) else { guard let next else { - throw NoRelaysSatisfyingConstraintsError() + throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) } diff --git a/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift index b43542893088..bfed08a41044 100644 --- a/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift +++ b/ios/MullvadREST/Relay/NoRelaysSatisfyingConstraintsError.swift @@ -8,10 +8,39 @@ import Foundation +public enum NoRelaysSatisfyingConstraintsReason { + case filterConstraintNotMatching + case invalidPort + case entryEqualsExit + case multihopInvalidFlow + case noActiveRelaysFound + case noDaitaRelaysFound + case relayConstraintNotMatching +} + public struct NoRelaysSatisfyingConstraintsError: LocalizedError { - public init() {} + public let reason: NoRelaysSatisfyingConstraintsReason public var errorDescription: String? { - "No relays satisfying constraints." + switch reason { + case .filterConstraintNotMatching: + "Filter yields no matching relays" + case .invalidPort: + "Invalid port selected by RelaySelector" + case .entryEqualsExit: + "Entry and exit relays are the same" + case .multihopInvalidFlow: + "Invalid multihop decision flow" + case .noActiveRelaysFound: + "No active relays found" + case .noDaitaRelaysFound: + "No DAITA relays found" + case .relayConstraintNotMatching: + "Invalid constraint created to pick a relay" + } + } + + public init(_ reason: NoRelaysSatisfyingConstraintsReason) { + self.reason = reason } } diff --git a/ios/MullvadREST/Relay/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking.swift index 0877d8908e3e..15ae2ee90ce5 100644 --- a/ios/MullvadREST/Relay/RelayPicking.swift +++ b/ios/MullvadREST/Relay/RelayPicking.swift @@ -37,24 +37,44 @@ extension RelayPicking { struct SinglehopPicker: RelayPicking { let constraints: RelayConstraints + let daitaSettings: DAITASettings let relays: REST.ServerRelaysResponse let connectionAttemptCount: UInt func pick() throws -> SelectedRelays { - let candidates = try RelaySelector.WireGuard.findCandidates( - by: constraints.exitLocations, - in: relays, - filterConstraint: constraints.filter - ) + var exitCandidates = [RelayWithLocation]() - let match = try findBestMatch(from: candidates) + do { + exitCandidates = try RelaySelector.WireGuard.findCandidates( + by: constraints.exitLocations, + in: relays, + filterConstraint: constraints.filter, + daitaEnabled: daitaSettings.state.isEnabled + ) + } catch let error as NoRelaysSatisfyingConstraintsError where error.reason == .noDaitaRelaysFound { + #if DEBUG + // If DAITA is enabled and no supported relays are found, we should try to find the nearest + // available relay that supports DAITA and use it as entry in a multihop selection. + var constraints = constraints + constraints.entryLocations = .any + + return try MultihopPicker( + constraints: constraints, + daitaSettings: daitaSettings, + relays: relays, + connectionAttemptCount: connectionAttemptCount + ).pick() + #endif + } + let match = try findBestMatch(from: exitCandidates) return SelectedRelays(entry: nil, exit: match, retryAttempt: connectionAttemptCount) } } struct MultihopPicker: RelayPicking { let constraints: RelayConstraints + let daitaSettings: DAITASettings let relays: REST.ServerRelaysResponse let connectionAttemptCount: UInt @@ -62,13 +82,15 @@ struct MultihopPicker: RelayPicking { let entryCandidates = try RelaySelector.WireGuard.findCandidates( by: constraints.entryLocations, in: relays, - filterConstraint: constraints.filter + filterConstraint: constraints.filter, + daitaEnabled: daitaSettings.state.isEnabled ) let exitCandidates = try RelaySelector.WireGuard.findCandidates( by: constraints.exitLocations, in: relays, - filterConstraint: constraints.filter + filterConstraint: constraints.filter, + daitaEnabled: false ) /* diff --git a/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift index 1f678e6027db..f529b9b92474 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift @@ -43,11 +43,12 @@ extension RelaySelector { in relaysResponse: REST.ServerRelaysResponse ) -> REST.BridgeRelay? { let mappedBridges = mapRelays(relays: relaysResponse.bridge.relays, locations: relaysResponse.locations) - let filteredRelays = applyConstraints( + let filteredRelays = (try? applyConstraints( location, filterConstraint: filter, + daitaEnabled: false, relays: mappedBridges - ) + )) ?? [] guard filteredRelays.isEmpty == false else { return relay(from: relaysResponse) } // Compute the midpoint location from all the filtered relays diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift index 1e611569417c..4c8561f38b5f 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift @@ -6,6 +6,7 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import MullvadTypes extension RelaySelector { @@ -14,13 +15,15 @@ extension RelaySelector { public static func findCandidates( by relayConstraint: RelayConstraint, in relays: REST.ServerRelaysResponse, - filterConstraint: RelayConstraint + filterConstraint: RelayConstraint, + daitaEnabled: Bool ) throws -> [RelayWithLocation] { let mappedRelays = mapRelays(relays: relays.wireguard.relays, locations: relays.locations) - return applyConstraints( + return try applyConstraints( relayConstraint, filterConstraint: filterConstraint, + daitaEnabled: daitaEnabled, relays: mappedRelays ) } @@ -38,8 +41,12 @@ extension RelaySelector { numberOfFailedAttempts: numberOfFailedAttempts ) - guard let port, let relayWithLocation = pickRandomRelayByWeight(relays: relayWithLocations) else { - throw NoRelaysSatisfyingConstraintsError() + guard let port else { + throw NoRelaysSatisfyingConstraintsError(.invalidPort) + } + + guard let relayWithLocation = pickRandomRelayByWeight(relays: relayWithLocations) else { + throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) } let endpoint = MullvadEndpoint( diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift index da4082a1b185..98279daca570 100644 --- a/ios/MullvadREST/Relay/RelaySelector.swift +++ b/ios/MullvadREST/Relay/RelaySelector.swift @@ -6,7 +6,7 @@ // Copyright © 2019 Mullvad VPN AB. All rights reserved. // -import Foundation +import MullvadSettings import MullvadTypes private let defaultPort: UInt16 = 53 @@ -135,74 +135,135 @@ public enum RelaySelector { static func applyConstraints( _ relayConstraint: RelayConstraint, filterConstraint: RelayConstraint, + daitaEnabled: Bool, relays: [RelayWithLocation] - ) -> [RelayWithLocation] { - // Filter on active status, filter, and location. - let filteredRelays = relays.filter { relayWithLocation -> Bool in - guard relayWithLocation.relay.active else { - return false - } + ) throws -> [RelayWithLocation] { + // Filter on active status, daita support, filter constraint and relay constraint. + var filteredRelays = try filterByActive(relays: relays) + filteredRelays = try filterByFilterConstraint(relays: filteredRelays, constraint: filterConstraint) + filteredRelays = try filterByLocationConstraint(relays: filteredRelays, constraint: relayConstraint) + filteredRelays = try filterByDaita(relays: filteredRelays, daitaEnabled: daitaEnabled) + return filterByCountryInclusion(relays: filteredRelays, constraint: relayConstraint) + } + + /// 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) + } + } + + private static func filterByActive( + relays: [RelayWithLocation] + ) throws -> [RelayWithLocation] { + let filteredRelays = relays.filter { relayWithLocation in + relayWithLocation.relay.active + } + + return if filteredRelays.isEmpty { + throw NoRelaysSatisfyingConstraintsError(.noActiveRelaysFound) + } else { + filteredRelays + } + } - switch filterConstraint { + private static func filterByDaita( + relays: [RelayWithLocation], + daitaEnabled: Bool + ) throws -> [RelayWithLocation] { + guard daitaEnabled else { return relays } + + let filteredRelays = relays.filter { relayWithLocation in + relayWithLocation.relay.daita == true + } + + return if filteredRelays.isEmpty { + throw NoRelaysSatisfyingConstraintsError(.noDaitaRelaysFound) + } else { + filteredRelays + } + } + + private static func filterByFilterConstraint( + relays: [RelayWithLocation], + constraint: RelayConstraint + ) throws -> [RelayWithLocation] { + let filteredRelays = relays.filter { relayWithLocation in + switch constraint { case .any: - break + true case let .only(filter): - if !relayMatchesFilter(relayWithLocation.relay, filter: filter) { - return false - } + relayMatchesFilter(relayWithLocation.relay, filter: filter) } + } - return switch relayConstraint { + return if filteredRelays.isEmpty { + throw NoRelaysSatisfyingConstraintsError(.filterConstraintNotMatching) + } else { + filteredRelays + } + } + + private static func filterByLocationConstraint( + relays: [RelayWithLocation], + constraint: RelayConstraint + ) throws -> [RelayWithLocation] { + let filteredRelays = relays.filter { relayWithLocation in + switch constraint { case .any: true - case let .only(relayConstraint): + case let .only(constraint): // At least one location must match the relay under test. - relayConstraint.locations.contains { location in + constraint.locations.contains { location in relayWithLocation.matches(location: location) } } } - // Filter on country inclusion. - let includeInCountryFilteredRelays = filteredRelays.filter { relayWithLocation in - return switch relayConstraint { + return if filteredRelays.isEmpty { + throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) + } else { + filteredRelays + } + } + + private static func filterByCountryInclusion( + relays: [RelayWithLocation], + constraint: RelayConstraint + ) -> [RelayWithLocation] { + let filteredRelays = relays.filter { relayWithLocation in + return switch constraint { case .any: true case let .only(relayConstraint): relayConstraint.locations.contains { location in if case .country = location { - return relayWithLocation.relay.includeInCountry + relayWithLocation.relay.includeInCountry + } else { + false } - return false } } } // If no relays should be included in the matched country, instead accept all. - if includeInCountryFilteredRelays.isEmpty { - return filteredRelays + return if filteredRelays.isEmpty { + relays } else { - return includeInCountryFilteredRelays - } - } - - /// 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) + filteredRelays } } } diff --git a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift index 839b19f84ae4..38dd42e72022 100644 --- a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift +++ b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift @@ -39,12 +39,14 @@ public final class RelaySelectorWrapper: RelaySelectorProtocol { case .off: return try SinglehopPicker( constraints: tunnelSettings.relayConstraints, + daitaSettings: tunnelSettings.daita, relays: relays, connectionAttemptCount: connectionAttemptCount ).pick() case .on: return try MultihopPicker( constraints: tunnelSettings.relayConstraints, + daitaSettings: tunnelSettings.daita, relays: relays, connectionAttemptCount: connectionAttemptCount ).pick() diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 08224e6e759e..ad58896f517d 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -57,7 +57,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { let locationViewControllerWrapper = LocationViewControllerWrapper( customListRepository: customListRepository, constraints: tunnelManager.settings.relayConstraints, - multihopEnabled: tunnelManager.settings.tunnelMultihopState == .on + multihopEnabled: tunnelManager.settings.tunnelMultihopState.isEnabled ) locationViewControllerWrapper.delegate = self diff --git a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift index 84b5e9828dd7..3e98da16c759 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift @@ -236,6 +236,12 @@ final class TunnelStatusNotificationProvider: NotificationProvider, InAppNotific switch error { case .outdatedSchema: errorString = "Unable to start tunnel connection after update. Please disconnect and reconnect." + case .noRelaysSatisfyingFilterConstraints: + errorString = "No servers match your location filter. Try changing filter settings." + case .multihopEntryEqualsExit: + errorString = "The entry and exit servers cannot be the same. Try changing one to a new server or location." + case .noRelaysSatisfyingDaitaConstraints: + errorString = "No DAITA compatible servers match your location settings. Try changing location." case .noRelaysSatisfyingConstraints: errorString = "No servers match your settings, try changing server or other settings." case .invalidAccount: diff --git a/ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift b/ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift index 13b0d443e37b..be0b8088a433 100644 --- a/ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift +++ b/ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift @@ -85,7 +85,8 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: true ), REST.ServerRelay( hostname: "se10-wireguard", @@ -97,7 +98,8 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: false ), REST.ServerRelay( hostname: "se2-wireguard", @@ -109,7 +111,8 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: false ), REST.ServerRelay( hostname: "se6-wireguard", @@ -121,7 +124,8 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: false ), REST.ServerRelay( hostname: "us-dal-wg-001", @@ -133,7 +137,8 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: true ), REST.ServerRelay( hostname: "us-nyc-wg-301", @@ -145,7 +150,21 @@ enum ServerRelaysResponseStubs { ipv4AddrIn: .loopback, ipv6AddrIn: .loopback, publicKey: PrivateKey().publicKey.rawValue, - includeInCountry: true + includeInCountry: true, + daita: true + ), + REST.ServerRelay( + hostname: "us-nyc-wg-302", + active: false, + owned: true, + location: "us-nyc", + provider: "", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: true ), ] ), diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift index d6d570ee9a73..190593778fed 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift @@ -7,6 +7,7 @@ // @testable import MullvadREST +@testable import MullvadSettings @testable import MullvadTypes import XCTest @@ -119,6 +120,7 @@ extension MultihopDecisionFlowTests { return MultihopPicker( constraints: constraints, + daitaSettings: DAITASettings(state: .off), relays: sampleRelays, connectionAttemptCount: 0 ) diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift index 3c9acec44561..9229d4ab8f31 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift @@ -9,6 +9,7 @@ import Foundation @testable import MullvadREST +@testable import MullvadSettings @testable import MullvadTypes import XCTest @@ -23,6 +24,7 @@ class RelayPickingTests: XCTestCase { let picker = SinglehopPicker( constraints: constraints, + daitaSettings: DAITASettings(state: .off), relays: sampleRelays, connectionAttemptCount: 0 ) @@ -41,6 +43,7 @@ class RelayPickingTests: XCTestCase { let picker = MultihopPicker( constraints: constraints, + daitaSettings: DAITASettings(state: .off), relays: sampleRelays, connectionAttemptCount: 0 ) @@ -59,10 +62,16 @@ class RelayPickingTests: XCTestCase { let picker = MultihopPicker( constraints: constraints, + daitaSettings: DAITASettings(state: .off), relays: sampleRelays, connectionAttemptCount: 0 ) - XCTAssertThrowsError(try picker.pick()) + XCTAssertThrowsError( + try picker.pick() + ) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .entryEqualsExit) + } } } diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift index 50df5635a0a5..2622d883fdef 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift @@ -7,8 +7,10 @@ // @testable import MullvadREST +@testable import MullvadSettings import MullvadTypes import Network +@testable import WireGuardKitTypes import XCTest private let portRanges: [[UInt16]] = [[4000, 4001], [5000, 5001]] @@ -17,8 +19,6 @@ private let defaultPort: UInt16 = 53 class RelaySelectorTests: XCTestCase { let sampleRelays = ServerRelaysResponseStubs.sampleRelays - // MARK: - single-Hop tests - func testCountryConstraint() throws { let constraints = RelayConstraints( exitLocations: .only(UserSelectedRelays(locations: [.country("es")])) @@ -71,9 +71,10 @@ class RelaySelectorTests: XCTestCase { ) } - let constrainedLocations = RelaySelector.applyConstraints( + let constrainedLocations = try RelaySelector.applyConstraints( constraints.exitLocations, filterConstraint: constraints.filter, + daitaEnabled: false, relays: relayWithLocations ) @@ -90,6 +91,19 @@ class RelaySelectorTests: XCTestCase { ) } + func testNoMatchingRelayConstraintError() throws { + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.country("-")])) + ) + + XCTAssertThrowsError( + try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0) + ) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .relayConstraintNotMatching) + } + } + func testSpecificPortConstraint() throws { let constraints = RelayConstraints( exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])), @@ -172,7 +186,10 @@ class RelaySelectorTests: XCTestCase { filter: .only(filter) ) - XCTAssertThrowsError(try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)) + XCTAssertThrowsError(try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .filterConstraintNotMatching) + } } func testRelayFilterConstraintWithCorrectProvider() throws { @@ -197,7 +214,44 @@ class RelaySelectorTests: XCTestCase { filter: .only(filter) ) - XCTAssertThrowsError(try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)) + XCTAssertThrowsError(try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .filterConstraintNotMatching) + } + } + + func testRelayWithDaita() throws { + let hasDaitaConstraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.country("es")])) + ) + + let noDaitaConstraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.country("se")])) + ) + + XCTAssertNoThrow( + try pickRelay( + by: hasDaitaConstraints, + in: sampleRelays, + failedAttemptCount: 0, + daitaEnabled: true + ) + ) + XCTAssertThrowsError( + try pickRelay(by: noDaitaConstraints, in: sampleRelays, failedAttemptCount: 0, daitaEnabled: true) + ) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .noDaitaRelaysFound) + } + } + + func testNoActiveRelaysError() throws { + XCTAssertThrowsError( + try pickRelay(by: RelayConstraints(), in: sampleRelaysNoActive, failedAttemptCount: 0) + ) { error in + let error = error as? NoRelaysSatisfyingConstraintsError + XCTAssertEqual(error?.reason, .noActiveRelaysFound) + } } } @@ -205,12 +259,14 @@ extension RelaySelectorTests { private func pickRelay( by constraints: RelayConstraints, in relays: REST.ServerRelaysResponse, - failedAttemptCount: UInt + failedAttemptCount: UInt, + daitaEnabled: Bool = false ) throws -> RelaySelectorMatch { let candidates = try RelaySelector.WireGuard.findCandidates( by: constraints.exitLocations, in: relays, - filterConstraint: constraints.filter + filterConstraint: constraints.filter, + daitaEnabled: daitaEnabled ) return try RelaySelector.WireGuard.pickCandidate( @@ -221,3 +277,39 @@ extension RelaySelectorTests { ) } } + +extension RelaySelectorTests { + var sampleRelaysNoActive: REST.ServerRelaysResponse { + REST.ServerRelaysResponse( + locations: [ + "es-mad": REST.ServerLocation( + country: "Spain", + city: "Madrid", + latitude: 40.408566, + longitude: -3.69222 + ), + ], + wireguard: REST.ServerWireguardTunnels( + ipv4Gateway: .loopback, + ipv6Gateway: .loopback, + portRanges: portRanges, + relays: [ + REST.ServerRelay( + hostname: "es1-wireguard", + active: false, + owned: true, + location: "es-mad", + provider: "", + weight: 500, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: PrivateKey().publicKey.rawValue, + includeInCountry: true, + daita: true + ), + ] + ), + bridge: REST.ServerBridges(shadowsocks: [], relays: []) + ) + } +} diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift index 6131b23d08bf..95b33883e831 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift @@ -52,4 +52,90 @@ class RelaySelectorWrapperTests: XCTestCase { let selectedRelays = try wrapper.selectRelays(connectionAttemptCount: 0) XCTAssertNotNil(selectedRelays.entry) } + + func testCanSelectRelayWithMultihopOnAndDaitaOn() throws { + let wrapper = RelaySelectorWrapper( + relayCache: relayCache, + tunnelSettingsUpdater: settingsUpdater + ) + + let constraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.country("es")])), // Relay with DAITA. + exitLocations: .only(UserSelectedRelays(locations: [.country("us")])) + ) + + let settings = LatestTunnelSettings( + relayConstraints: constraints, + tunnelMultihopState: .on, + daita: DAITASettings(state: .on) + ) + settingsListener.onNewSettings?(settings) + + XCTAssertNoThrow(try wrapper.selectRelays(connectionAttemptCount: 0)) + } + + func testCannotSelectRelayWithMultihopOnAndDaitaOn() throws { + let wrapper = RelaySelectorWrapper( + relayCache: relayCache, + tunnelSettingsUpdater: settingsUpdater + ) + + let constraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.country("se")])), // Relay without DAITA. + exitLocations: .only(UserSelectedRelays(locations: [.country("us")])) + ) + + let settings = LatestTunnelSettings( + relayConstraints: constraints, + tunnelMultihopState: .on, + daita: DAITASettings(state: .on) + ) + settingsListener.onNewSettings?(settings) + + XCTAssertThrowsError(try wrapper.selectRelays(connectionAttemptCount: 0)) + } + + func testCanSelectRelayWithMultihopOffAndDaitaOn() throws { + let wrapper = RelaySelectorWrapper( + relayCache: relayCache, + tunnelSettingsUpdater: settingsUpdater + ) + + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.country("es")])) // Relay with DAITA. + ) + + let settings = LatestTunnelSettings( + relayConstraints: constraints, + tunnelMultihopState: .off, + daita: DAITASettings(state: .on) + ) + settingsListener.onNewSettings?(settings) + + let selectedRelays = try wrapper.selectRelays(connectionAttemptCount: 0) + XCTAssertNil(selectedRelays.entry) + } + + // If DAITA is enabled and no supported relays are found, we should try to find the nearest + // available relay that supports DAITA and use it as entry in a multihop selection. + func testCanSelectRelayWithMultihopOffAndDaitaOnThroughMultihop() throws { + let wrapper = RelaySelectorWrapper( + relayCache: relayCache, + tunnelSettingsUpdater: settingsUpdater + ) + + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.country("se")])) // Relay without DAITA. + ) + + let settings = LatestTunnelSettings( + relayConstraints: constraints, + tunnelMultihopState: .off, + daita: DAITASettings(state: .on) + ) + settingsListener.onNewSettings?(settings) + + let selectedRelays = try wrapper.selectRelays(connectionAttemptCount: 0) + XCTAssertNotNil(selectedRelays.entry) + } } diff --git a/ios/MullvadVPNTests/MullvadSettings/IPOverrideWrapperTests.swift b/ios/MullvadVPNTests/MullvadSettings/IPOverrideWrapperTests.swift index bbaba178abce..a5e0d742e6d5 100644 --- a/ios/MullvadVPNTests/MullvadSettings/IPOverrideWrapperTests.swift +++ b/ios/MullvadVPNTests/MullvadSettings/IPOverrideWrapperTests.swift @@ -83,7 +83,8 @@ extension IPOverrideWrapperTests { ipv4AddrIn: .any, ipv6AddrIn: .any, publicKey: Data(), - includeInCountry: true + includeInCountry: true, + daita: false ) } diff --git a/ios/PacketTunnel/PacketTunnelProvider/BlockedStateErrorMapper.swift b/ios/PacketTunnel/PacketTunnelProvider/BlockedStateErrorMapper.swift index 5038839fa53f..df709b53f5b5 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/BlockedStateErrorMapper.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/BlockedStateErrorMapper.swift @@ -47,9 +47,18 @@ public struct BlockedStateErrorMapper: BlockedStateErrorMapperProtocol { return .readSettings } - case is NoRelaysSatisfyingConstraintsError: - // Returned by relay selector when there are no relays satisfying the given constraint. - return .noRelaysSatisfyingConstraints + case let error as NoRelaysSatisfyingConstraintsError: + // Returned by relay selector when there are no relays satisfying the given constraints. + return switch error.reason { + case .filterConstraintNotMatching: + .noRelaysSatisfyingFilterConstraints + case .entryEqualsExit: + .multihopEntryEqualsExit + case .noDaitaRelaysFound: + .noRelaysSatisfyingDaitaConstraints + default: + .noRelaysSatisfyingConstraints + } case is WireGuardAdapterError: // Any errors that originate from wireguard adapter including failure to set tunnel settings using diff --git a/ios/PacketTunnelCore/Actor/State+Extensions.swift b/ios/PacketTunnelCore/Actor/State+Extensions.swift index f7d8cbfae796..4399942eade4 100644 --- a/ios/PacketTunnelCore/Actor/State+Extensions.swift +++ b/ios/PacketTunnelCore/Actor/State+Extensions.swift @@ -194,7 +194,9 @@ extension BlockedStateReason { case .deviceLocked: return true - case .noRelaysSatisfyingConstraints, .readSettings, .invalidAccount, .accountExpired, .deviceRevoked, + case .noRelaysSatisfyingConstraints, .noRelaysSatisfyingFilterConstraints, + .multihopEntryEqualsExit, + .noRelaysSatisfyingDaitaConstraints, .readSettings, .invalidAccount, .accountExpired, .deviceRevoked, .tunnelAdapter, .unknown, .deviceLoggedOut, .outdatedSchema, .invalidRelayPublicKey: return false } diff --git a/ios/PacketTunnelCore/Actor/State.swift b/ios/PacketTunnelCore/Actor/State.swift index ee7e00ded31b..f3c1b012b29e 100644 --- a/ios/PacketTunnelCore/Actor/State.swift +++ b/ios/PacketTunnelCore/Actor/State.swift @@ -192,9 +192,18 @@ public enum BlockedStateReason: String, Codable, Equatable { /// Settings schema is outdated. case outdatedSchema - /// No relay satisfying constraints. + /// General error for no relays satisfying constraints. case noRelaysSatisfyingConstraints + /// No relays satisfying filter constraints. + case noRelaysSatisfyingFilterConstraints + + /// No relays satisfying multihop constraints. + case multihopEntryEqualsExit + + /// No relays satisfying DAITA constraints. + case noRelaysSatisfyingDaitaConstraints + /// Any other failure when reading settings. case readSettings diff --git a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift index 8e2a42fd886e..b300befc5a32 100644 --- a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift +++ b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift @@ -85,7 +85,8 @@ final class AppMessageHandlerTests: XCTestCase { let candidates = try RelaySelector.WireGuard.findCandidates( by: relayConstraints.exitLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ) let match = try RelaySelector.WireGuard.pickCandidate( diff --git a/ios/PacketTunnelCoreTests/MultiHopPostQuantumKeyExchangingTests.swift b/ios/PacketTunnelCoreTests/MultiHopPostQuantumKeyExchangingTests.swift index fea8988aa8a8..5b8968b1ea64 100644 --- a/ios/PacketTunnelCoreTests/MultiHopPostQuantumKeyExchangingTests.swift +++ b/ios/PacketTunnelCoreTests/MultiHopPostQuantumKeyExchangingTests.swift @@ -27,7 +27,8 @@ final class MultiHopPostQuantumKeyExchangingTests: XCTestCase { from: try RelaySelector.WireGuard.findCandidates( by: relayConstraints.exitLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ), relays: ServerRelaysResponseStubs.sampleRelays, portConstraint: relayConstraints.port, @@ -38,7 +39,8 @@ final class MultiHopPostQuantumKeyExchangingTests: XCTestCase { from: try RelaySelector.WireGuard.findCandidates( by: relayConstraints.entryLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ), relays: ServerRelaysResponseStubs.sampleRelays, portConstraint: relayConstraints.port, diff --git a/ios/PacketTunnelCoreTests/PostQuantumKeyExchangingPipelineTests.swift b/ios/PacketTunnelCoreTests/PostQuantumKeyExchangingPipelineTests.swift index ba45034935e2..f0725be312cd 100644 --- a/ios/PacketTunnelCoreTests/PostQuantumKeyExchangingPipelineTests.swift +++ b/ios/PacketTunnelCoreTests/PostQuantumKeyExchangingPipelineTests.swift @@ -28,7 +28,8 @@ final class PostQuantumKeyExchangingPipelineTests: XCTestCase { from: try RelaySelector.WireGuard.findCandidates( by: relayConstraints.exitLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ), relays: ServerRelaysResponseStubs.sampleRelays, portConstraint: relayConstraints.port, @@ -39,7 +40,8 @@ final class PostQuantumKeyExchangingPipelineTests: XCTestCase { from: try RelaySelector.WireGuard.findCandidates( by: relayConstraints.entryLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ), relays: ServerRelaysResponseStubs.sampleRelays, portConstraint: relayConstraints.port, diff --git a/ios/PacketTunnelCoreTests/SingleHopPostQuantumKeyExchangingTests.swift b/ios/PacketTunnelCoreTests/SingleHopPostQuantumKeyExchangingTests.swift index fbadc64a66bd..b052ad7762c5 100644 --- a/ios/PacketTunnelCoreTests/SingleHopPostQuantumKeyExchangingTests.swift +++ b/ios/PacketTunnelCoreTests/SingleHopPostQuantumKeyExchangingTests.swift @@ -24,7 +24,8 @@ final class SingleHopPostQuantumKeyExchangingTests: XCTestCase { let candidates = try RelaySelector.WireGuard.findCandidates( by: relayConstraints.exitLocations, in: ServerRelaysResponseStubs.sampleRelays, - filterConstraint: relayConstraints.filter + filterConstraint: relayConstraints.filter, + daitaEnabled: false ) let match = try RelaySelector.WireGuard.pickCandidate(