diff --git a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift index 610cdbb5e3e6..a8706f39fa9b 100644 --- a/ios/MullvadREST/Relay/MultihopDecisionFlow.swift +++ b/ios/MullvadREST/Relay/MultihopDecisionFlow.swift @@ -12,7 +12,11 @@ protocol MultihopDecisionFlow { typealias RelayCandidate = RelayWithLocation init(next: MultihopDecisionFlow?, relayPicker: RelayPicking) func canHandle(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) -> Bool - func pick(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) throws -> SelectedRelays + func pick( + entryCandidates: [RelayCandidate], + exitCandidates: [RelayCandidate], + automaticDaitaRouting: Bool + ) throws -> SelectedRelays } struct OneToOne: MultihopDecisionFlow { @@ -23,20 +27,32 @@ struct OneToOne: MultihopDecisionFlow { self.relayPicker = relayPicker } - func pick(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) throws -> SelectedRelays { + func pick( + entryCandidates: [RelayCandidate], + exitCandidates: [RelayCandidate], + automaticDaitaRouting: Bool + ) throws -> SelectedRelays { guard canHandle(entryCandidates: entryCandidates, exitCandidates: exitCandidates) else { guard let next else { throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } - return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + return try next.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: automaticDaitaRouting + ) } guard entryCandidates.first != exitCandidates.first else { throw NoRelaysSatisfyingConstraintsError(.entryEqualsExit) } - let entryMatch = try relayPicker.findBestMatch(from: entryCandidates) let exitMatch = try relayPicker.findBestMatch(from: exitCandidates) + let entryMatch = try relayPicker.findBestMatch( + from: entryCandidates, + closeTo: automaticDaitaRouting ? exitMatch.location : nil + ) + return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) } @@ -54,7 +70,11 @@ struct OneToMany: MultihopDecisionFlow { self.relayPicker = relayPicker } - func pick(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) throws -> SelectedRelays { + func pick( + entryCandidates: [RelayCandidate], + exitCandidates: [RelayCandidate], + automaticDaitaRouting: Bool + ) throws -> SelectedRelays { guard let multihopPicker = relayPicker as? MultihopPicker else { fatalError("Could not cast picker to MultihopPicker") } @@ -63,24 +83,70 @@ struct OneToMany: MultihopDecisionFlow { guard let next else { throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } - return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + return try next.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: automaticDaitaRouting + ) + } + + guard !automaticDaitaRouting else { + return try ManyToOne(next: next, relayPicker: relayPicker) + .pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates, automaticDaitaRouting: true) + } + + let entryMatch = try multihopPicker.findBestMatch(from: entryCandidates) + let exitMatch = try multihopPicker.exclude(relay: entryMatch, from: exitCandidates) + + return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) + } + + func canHandle(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) -> Bool { + entryCandidates.count == 1 && exitCandidates.count > 1 + } +} + +struct ManyToOne: MultihopDecisionFlow { + let next: MultihopDecisionFlow? + let relayPicker: RelayPicking + + init(next: (any MultihopDecisionFlow)?, relayPicker: RelayPicking) { + self.next = next + self.relayPicker = relayPicker + } + + func pick( + entryCandidates: [RelayCandidate], + exitCandidates: [RelayCandidate], + automaticDaitaRouting: Bool + ) throws -> SelectedRelays { + guard let multihopPicker = relayPicker as? MultihopPicker else { + fatalError("Could not cast picker to MultihopPicker") } - switch (entryCandidates.count, exitCandidates.count) { - case let (1, count) where count > 1: - let entryMatch = try multihopPicker.findBestMatch(from: entryCandidates) - let exitMatch = try multihopPicker.exclude(relay: entryMatch, from: exitCandidates) - return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) - default: - let exitMatch = try multihopPicker.findBestMatch(from: exitCandidates) - let entryMatch = try multihopPicker.exclude(relay: exitMatch, from: entryCandidates) - return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) + guard canHandle(entryCandidates: entryCandidates, exitCandidates: exitCandidates) else { + guard let next else { + throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) + } + return try next.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: automaticDaitaRouting + ) } + + let exitMatch = try multihopPicker.findBestMatch(from: exitCandidates) + let entryMatch = try multihopPicker.exclude( + relay: exitMatch, + from: entryCandidates, + closeTo: automaticDaitaRouting ? exitMatch.location : nil + ) + + return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) } func canHandle(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) -> Bool { - (entryCandidates.count == 1 && exitCandidates.count > 1) || - (entryCandidates.count > 1 && exitCandidates.count == 1) + entryCandidates.count > 1 && exitCandidates.count == 1 } } @@ -93,7 +159,11 @@ struct ManyToMany: MultihopDecisionFlow { self.relayPicker = relayPicker } - func pick(entryCandidates: [RelayCandidate], exitCandidates: [RelayCandidate]) throws -> SelectedRelays { + func pick( + entryCandidates: [RelayCandidate], + exitCandidates: [RelayCandidate], + automaticDaitaRouting: Bool + ) throws -> SelectedRelays { guard let multihopPicker = relayPicker as? MultihopPicker else { fatalError("Could not cast picker to MultihopPicker") } @@ -102,11 +172,20 @@ struct ManyToMany: MultihopDecisionFlow { guard let next else { throw NoRelaysSatisfyingConstraintsError(.multihopInvalidFlow) } - return try next.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + return try next.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: automaticDaitaRouting + ) } let exitMatch = try multihopPicker.findBestMatch(from: exitCandidates) - let entryMatch = try multihopPicker.exclude(relay: exitMatch, from: entryCandidates) + let entryMatch = try multihopPicker.exclude( + relay: exitMatch, + from: entryCandidates, + closeTo: automaticDaitaRouting ? exitMatch.location : nil + ) + return SelectedRelays(entry: entryMatch, exit: exitMatch, retryAttempt: relayPicker.connectionAttemptCount) } diff --git a/ios/MullvadREST/Relay/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking.swift index 4d752d37079d..a3680e28f315 100644 --- a/ios/MullvadREST/Relay/RelayPicking.swift +++ b/ios/MullvadREST/Relay/RelayPicking.swift @@ -13,18 +13,21 @@ protocol RelayPicking { var relays: REST.ServerRelaysResponse { get } var constraints: RelayConstraints { get } var connectionAttemptCount: UInt { get } + var daitaSettings: DAITASettings { get } func pick() throws -> SelectedRelays } extension RelayPicking { func findBestMatch( - from candidates: [RelayWithLocation] + from candidates: [RelayWithLocation], + closeTo location: Location? = nil ) throws -> SelectedRelay { let match = try RelaySelector.WireGuard.pickCandidate( from: candidates, relays: relays, portConstraint: constraints.port, - numberOfFailedAttempts: connectionAttemptCount + numberOfFailedAttempts: connectionAttemptCount, + closeTo: location ) return SelectedRelay( @@ -36,56 +39,51 @@ extension RelayPicking { } struct SinglehopPicker: RelayPicking { - let constraints: RelayConstraints - let daitaSettings: DAITASettings let relays: REST.ServerRelaysResponse + let constraints: RelayConstraints let connectionAttemptCount: UInt + let daitaSettings: DAITASettings func pick() throws -> SelectedRelays { - var exitCandidates = [RelayWithLocation]() - do { - exitCandidates = try RelaySelector.WireGuard.findCandidates( + let exitCandidates = try RelaySelector.WireGuard.findCandidates( by: constraints.exitLocations, in: relays, filterConstraint: constraints.filter, daitaEnabled: daitaSettings.daitaState.isEnabled ) + + let match = try findBestMatch(from: exitCandidates) + return SelectedRelays(entry: nil, exit: match, retryAttempt: connectionAttemptCount) } 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 + // If DAITA is on and Direct only is off, 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 + if daitaSettings.shouldDoAutomaticRouting { + var constraints = constraints + constraints.entryLocations = .any + + return try MultihopPicker( + relays: relays, + constraints: constraints, + connectionAttemptCount: connectionAttemptCount, + daitaSettings: daitaSettings, + automaticDaitaRouting: true + ).pick() + } else { + throw error + } } - - 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 constraints: RelayConstraints let connectionAttemptCount: UInt + let daitaSettings: DAITASettings + let automaticDaitaRouting: Bool func pick() throws -> SelectedRelays { - let entryCandidates = try RelaySelector.WireGuard.findCandidates( - by: constraints.entryLocations, - in: relays, - filterConstraint: constraints.filter, - daitaEnabled: daitaSettings.daitaState.isEnabled - ) - let exitCandidates = try RelaySelector.WireGuard.findCandidates( by: constraints.exitLocations, in: relays, @@ -96,15 +94,20 @@ struct MultihopPicker: RelayPicking { /* Relay selection is prioritised in the following order: 1. Both entry and exit constraints match only a single relay. Both relays are selected. - 2. Either entry or exit constraint matches only a single relay and the other multiple relays. The single relays - is selected and excluded from the list of multiple relays. - 3. Both entry and exit constraints match multiple relays. Exit relay is picked first and then excluded from - the list of entry relays. + 2. Entry constraint matches only a single relay and the other multiple relays. The single relay + is selected and excluded from the list of multiple relays. + 3. Exit constraint matches multiple relays and the other a single relay. The single relay + is selected and excluded from the list of multiple relays. + 4. Both entry and exit constraints match multiple relays. Exit relay is picked first and then + excluded from the list of entry relays. */ let decisionFlow = OneToOne( next: OneToMany( - next: ManyToMany( - next: nil, + next: ManyToOne( + next: ManyToMany( + next: nil, + relayPicker: self + ), relayPicker: self ), relayPicker: self @@ -112,17 +115,50 @@ struct MultihopPicker: RelayPicking { relayPicker: self ) - return try decisionFlow.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + do { + let entryCandidates = try RelaySelector.WireGuard.findCandidates( + by: constraints.entryLocations, + in: relays, + filterConstraint: constraints.filter, + daitaEnabled: daitaSettings.daitaState.isEnabled + ) + + return try decisionFlow.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: automaticDaitaRouting + ) + } catch let error as NoRelaysSatisfyingConstraintsError where error.reason == .noDaitaRelaysFound { + // If DAITA is on and Direct only is off, 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. + if daitaSettings.shouldDoAutomaticRouting { + let entryCandidates = try RelaySelector.WireGuard.findCandidates( + by: .any, + in: relays, + filterConstraint: constraints.filter, + daitaEnabled: true + ) + + return try decisionFlow.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: true + ) + } else { + throw error + } + } } func exclude( relay: SelectedRelay, - from candidates: [RelayWithLocation] + from candidates: [RelayWithLocation], + closeTo location: Location? = nil ) throws -> SelectedRelay { let filteredCandidates = candidates.filter { relayWithLocation in relayWithLocation.relay.hostname != relay.hostname } - return try findBestMatch(from: filteredCandidates) + return try findBestMatch(from: filteredCandidates, closeTo: location) } } diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift index 4c8561f38b5f..0317788e236c 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift @@ -33,38 +33,106 @@ extension RelaySelector { from relayWithLocations: [RelayWithLocation], relays: REST.ServerRelaysResponse, portConstraint: RelayConstraint, - numberOfFailedAttempts: UInt + numberOfFailedAttempts: UInt, + closeTo referenceLocation: Location? = nil ) throws -> RelaySelectorMatch { - let port = applyPortConstraint( - portConstraint, - rawPortRanges: relays.wireguard.portRanges, + let port = try evaluatePort( + relays: relays, + portConstraint: portConstraint, numberOfFailedAttempts: numberOfFailedAttempts ) - guard let port else { - throw NoRelaysSatisfyingConstraintsError(.invalidPort) + var relayWithLocation: RelayWithLocation? + if let referenceLocation { + let relay = closestRelay(to: referenceLocation, using: relayWithLocations) + relayWithLocation = relayWithLocations.first(where: { $0.relay == relay }) } - guard let relayWithLocation = pickRandomRelayByWeight(relays: relayWithLocations) else { + guard + let relayWithLocation = relayWithLocation ?? pickRandomRelayByWeight(relays: relayWithLocations) + else { throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) } - 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 createMatch(for: relayWithLocation, port: port, relays: relays) + } - return RelaySelectorMatch( - endpoint: endpoint, - relay: relayWithLocation.relay, - location: relayWithLocation.serverLocation - ) + public static func closestRelay( + to location: Location, + using relayWithLocations: [RelayWithLocation] + ) -> REST.ServerRelay? { + let relaysWithDistance = relayWithLocations.map { + RelayWithDistance( + relay: $0.relay, + distance: Haversine.distance( + location.latitude, + location.longitude, + $0.serverLocation.latitude, + $0.serverLocation.longitude + ) + ) + }.sorted { + $0.distance < $1.distance + }.prefix(5) + + let relaysGroupedByDistance = Dictionary(grouping: relaysWithDistance, by: { $0.distance }) + guard let closetsRelayGroup = relaysGroupedByDistance.min(by: { $0.key < $1.key })?.value else { + return nil + } + + var greatestDistance = 0.0 + closetsRelayGroup.forEach { + if $0.distance > greatestDistance { + greatestDistance = $0.distance + } + } + + let closestRelay = rouletteSelection(relays: closetsRelayGroup, weightFunction: { relay in + UInt64(1 + greatestDistance - relay.distance) + }) + + return closestRelay?.relay } } + + private static func evaluatePort( + relays: REST.ServerRelaysResponse, + portConstraint: RelayConstraint, + numberOfFailedAttempts: UInt + ) throws -> UInt16 { + let port = applyPortConstraint( + portConstraint, + rawPortRanges: relays.wireguard.portRanges, + numberOfFailedAttempts: numberOfFailedAttempts + ) + + guard let port else { + throw NoRelaysSatisfyingConstraintsError(.invalidPort) + } + + return port + } + + private static func createMatch( + for relayWithLocation: RelayWithLocation, + port: UInt16, + relays: REST.ServerRelaysResponse + ) -> RelaySelectorMatch { + 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/RelaySelectorWrapper.swift b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift index 32429d93cd36..a3fe1c216f9e 100644 --- a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift +++ b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift @@ -22,20 +22,21 @@ public final class RelaySelectorWrapper: RelaySelectorProtocol { ) throws -> SelectedRelays { let relays = try relayCache.read().relays - switch tunnelSettings.tunnelMultihopState { + return switch tunnelSettings.tunnelMultihopState { case .off: - return try SinglehopPicker( - constraints: tunnelSettings.relayConstraints, - daitaSettings: tunnelSettings.daita, + try SinglehopPicker( relays: relays, - connectionAttemptCount: connectionAttemptCount + constraints: tunnelSettings.relayConstraints, + connectionAttemptCount: connectionAttemptCount, + daitaSettings: tunnelSettings.daita ).pick() case .on: - return try MultihopPicker( + try MultihopPicker( + relays: relays, constraints: tunnelSettings.relayConstraints, + connectionAttemptCount: connectionAttemptCount, daitaSettings: tunnelSettings.daita, - relays: relays, - connectionAttemptCount: connectionAttemptCount + automaticDaitaRouting: false ).pick() } } diff --git a/ios/MullvadSettings/DAITASettings.swift b/ios/MullvadSettings/DAITASettings.swift index 9e0a0f1fb778..344e56f166d9 100644 --- a/ios/MullvadSettings/DAITASettings.swift +++ b/ios/MullvadSettings/DAITASettings.swift @@ -8,7 +8,7 @@ import Foundation -/// Whether DAITA is enabled +/// Whether DAITA is enabled. public enum DAITAState: Codable { case on case off @@ -18,8 +18,8 @@ public enum DAITAState: Codable { } } -/// Whether smart routing is enabled -public enum SmartRoutingState: Codable { +/// Whether "direct only" is enabled, meaning no automatic routing to DAITA relays. +public enum DirectOnlyState: Codable { case on case off @@ -38,11 +38,15 @@ public struct DAITASettings: Codable, Equatable { public let state: DAITAState = .off public let daitaState: DAITAState - public let smartRoutingState: SmartRoutingState + public let directOnlyState: DirectOnlyState - public init(daitaState: DAITAState = .off, smartRoutingState: SmartRoutingState = .off) { + public var shouldDoAutomaticRouting: Bool { + daitaState.isEnabled && !directOnlyState.isEnabled + } + + public init(daitaState: DAITAState = .off, directOnlyState: DirectOnlyState = .off) { self.daitaState = daitaState - self.smartRoutingState = smartRoutingState + self.directOnlyState = directOnlyState } public init(from decoder: any Decoder) throws { @@ -52,7 +56,7 @@ public struct DAITASettings: Codable, Equatable { ?? container.decodeIfPresent(DAITAState.self, forKey: .state) ?? .off - smartRoutingState = try container.decodeIfPresent(SmartRoutingState.self, forKey: .smartRoutingState) + directOnlyState = try container.decodeIfPresent(DirectOnlyState.self, forKey: .directOnlyState) ?? .off } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 594251948617..8c5a8f19acfd 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -574,6 +574,7 @@ 7A9F29392CABFAFC005F2089 /* InfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9F29382CABFAEC005F2089 /* InfoHeaderView.swift */; }; 7A9F293B2CAC4443005F2089 /* InfoHeaderConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9F293A2CAC4420005F2089 /* InfoHeaderConfig.swift */; }; 7A9F293D2CAD2FD5005F2089 /* InfoModalConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9F293C2CAD2FCF005F2089 /* InfoModalConfig.swift */; }; + 7A9F28FC2CA69D0C005F2089 /* DAITASettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9F28FB2CA69D04005F2089 /* DAITASettingsTests.swift */; }; 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; }; 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; }; 7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; }; @@ -1891,6 +1892,7 @@ 7A9F29382CABFAEC005F2089 /* InfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoHeaderView.swift; sourceTree = ""; }; 7A9F293A2CAC4420005F2089 /* InfoHeaderConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoHeaderConfig.swift; sourceTree = ""; }; 7A9F293C2CAD2FCF005F2089 /* InfoModalConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoModalConfig.swift; sourceTree = ""; }; + 7A9F28FB2CA69D04005F2089 /* DAITASettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettingsTests.swift; sourceTree = ""; }; 7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = ""; }; 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = ""; }; 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = ""; }; @@ -2573,6 +2575,7 @@ children = ( 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */, 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */, + 7A9F28FB2CA69D04005F2089 /* DAITASettingsTests.swift */, A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */, 7ADCB2D92B6A730400C88F89 /* IPOverrideRepositoryStub.swift */, 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */, @@ -5413,6 +5416,7 @@ A9A5FA2C2ACB05160083449F /* DeviceCheckOperationTests.swift in Sources */, A9A5FA2D2ACB05160083449F /* DurationTests.swift in Sources */, A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */, + 7A9F28FC2CA69D0C005F2089 /* DAITASettingsTests.swift in Sources */, A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */, 7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */, F04413622BA45CE30018A6EE /* CustomListLocationNodeBuilder.swift in Sources */, diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift index efcf9b1f41e1..ea55431d5a3e 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelState.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift @@ -118,7 +118,7 @@ enum TunnelState: Equatable, CustomStringConvertible { """ negotiating key with exit relay: \(tunnelRelays.exit.hostname)\ \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? "")\ - "isPostQuantum: \(isPostQuantum), isDaita: \(isDaita) + , isPostQuantum: \(isPostQuantum), isDaita: \(isDaita) """ } } diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift index 7f004ad5a91e..b315b899d479 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift @@ -37,8 +37,8 @@ class MultihopDecisionFlowTests: XCTestCase { let oneToMany = OneToMany(next: nil, relayPicker: picker) XCTAssertTrue(oneToMany.canHandle( - entryCandidates: [seSto2, seSto6], - exitCandidates: [seSto2] + entryCandidates: [seSto2], + exitCandidates: [seSto2, seSto6] )) XCTAssertFalse(oneToMany.canHandle( @@ -52,6 +52,25 @@ class MultihopDecisionFlowTests: XCTestCase { )) } + func testManyToOneCanHandle() throws { + let manyToOne = ManyToOne(next: nil, relayPicker: picker) + + XCTAssertTrue(manyToOne.canHandle( + entryCandidates: [seSto2, seSto6], + exitCandidates: [seSto2] + )) + + XCTAssertFalse(manyToOne.canHandle( + entryCandidates: [seSto6], + exitCandidates: [seSto2] + )) + + XCTAssertFalse(manyToOne.canHandle( + entryCandidates: [seSto2, seSto6], + exitCandidates: [seSto2, seSto6] + )) + } + func testManyToManyCanHandle() throws { let manyToMany = ManyToMany(next: nil, relayPicker: picker) @@ -77,7 +96,11 @@ class MultihopDecisionFlowTests: XCTestCase { let entryCandidates = [seSto2] let exitCandidates = [seSto6] - let selectedRelays = try oneToOne.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + let selectedRelays = try oneToOne.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: false + ) XCTAssertEqual(selectedRelays.entry?.hostname, "se2-wireguard") XCTAssertEqual(selectedRelays.exit.hostname, "se6-wireguard") @@ -86,10 +109,30 @@ class MultihopDecisionFlowTests: XCTestCase { func testOneToManyPick() throws { let oneToMany = OneToMany(next: nil, relayPicker: picker) + let entryCandidates = [seSto2] + let exitCandidates = [seSto2, seSto6] + + let selectedRelays = try oneToMany.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: false + ) + + XCTAssertEqual(selectedRelays.entry?.hostname, "se2-wireguard") + XCTAssertEqual(selectedRelays.exit.hostname, "se6-wireguard") + } + + func testManyToOnePick() throws { + let manyToOne = ManyToOne(next: nil, relayPicker: picker) + let entryCandidates = [seSto2, seSto6] let exitCandidates = [seSto2] - let selectedRelays = try oneToMany.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + let selectedRelays = try manyToOne.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: false + ) XCTAssertEqual(selectedRelays.entry?.hostname, "se6-wireguard") XCTAssertEqual(selectedRelays.exit.hostname, "se2-wireguard") @@ -101,7 +144,11 @@ class MultihopDecisionFlowTests: XCTestCase { let entryCandidates = [seSto2, seSto6] let exitCandidates = [seSto2, seSto6] - let selectedRelays = try manyToMany.pick(entryCandidates: entryCandidates, exitCandidates: exitCandidates) + let selectedRelays = try manyToMany.pick( + entryCandidates: entryCandidates, + exitCandidates: exitCandidates, + automaticDaitaRouting: false + ) if selectedRelays.exit.hostname == "se2-wireguard" { XCTAssertEqual(selectedRelays.entry?.hostname, "se6-wireguard") @@ -119,10 +166,11 @@ extension MultihopDecisionFlowTests { ) return MultihopPicker( + relays: sampleRelays, constraints: constraints, + connectionAttemptCount: 0, daitaSettings: DAITASettings(daitaState: .off), - relays: sampleRelays, - connectionAttemptCount: 0 + automaticDaitaRouting: false ) } diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift index 73d538ab1b09..fb42ba17f705 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift @@ -23,10 +23,10 @@ class RelayPickingTests: XCTestCase { ) let picker = SinglehopPicker( - constraints: constraints, - daitaSettings: DAITASettings(daitaState: .off), relays: sampleRelays, - connectionAttemptCount: 0 + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings() ) let selectedRelays = try picker.pick() @@ -42,10 +42,11 @@ class RelayPickingTests: XCTestCase { ) let picker = MultihopPicker( - constraints: constraints, - daitaSettings: DAITASettings(daitaState: .off), relays: sampleRelays, - connectionAttemptCount: 0 + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(), + automaticDaitaRouting: false ) let selectedRelays = try picker.pick() @@ -61,10 +62,11 @@ class RelayPickingTests: XCTestCase { ) let picker = MultihopPicker( - constraints: constraints, - daitaSettings: DAITASettings(daitaState: .off), relays: sampleRelays, - connectionAttemptCount: 0 + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(), + automaticDaitaRouting: false ) XCTAssertThrowsError( @@ -74,4 +76,127 @@ class RelayPickingTests: XCTestCase { XCTAssertEqual(error?.reason, .entryEqualsExit) } } + + func testDirectOnlyOffDaitaOnForSinglehopWithoutDaitaRelay() throws { + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .off) + ) + + let selectedRelays = try picker.pick() + + XCTAssertEqual(selectedRelays.entry?.hostname, "es1-wireguard") // Madrid relay is closest to exit relay. + XCTAssertEqual(selectedRelays.exit.hostname, "se10-wireguard") + } + + func testDirectOnlyOnDaitaOnForSinglehopWithoutDaitaRelay() throws { + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .on) + ) + + XCTAssertThrowsError(try picker.pick()) + } + + func testDirectOnlyOffDaitaOnForSinglehopWithDaitaRelay() throws { + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .off) + ) + + let selectedRelays = try picker.pick() + + XCTAssertNil(selectedRelays.entry?.hostname) + XCTAssertEqual(selectedRelays.exit.hostname, "es1-wireguard") + } + + func testDirectOnlyOnDaitaOnForSinglehopWithDaitaRelay() throws { + let constraints = RelayConstraints( + exitLocations: .only(UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .on) + ) + + let selectedRelays = try picker.pick() + + XCTAssertNil(selectedRelays.entry?.hostname) + XCTAssertEqual(selectedRelays.exit.hostname, "es1-wireguard") + } + + func testDirectOnlyOffDaitaOnForMultihopWithDaitaRelay() throws { + let constraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.hostname("us", "nyc", "us-nyc-wg-301")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .off) + ) + + let selectedRelays = try picker.pick() + + XCTAssertEqual(selectedRelays.entry?.hostname, "es1-wireguard") // Madrid relay is closest to exit relay. + XCTAssertEqual(selectedRelays.exit.hostname, "se10-wireguard") + } + + func testDirectOnlyOffDaitaOnForMultihopWithoutDaitaRelay() throws { + let constraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .off) + ) + + let selectedRelays = try picker.pick() + + XCTAssertEqual(selectedRelays.entry?.hostname, "es1-wireguard") // Madrid relay is closest to exit relay. + XCTAssertEqual(selectedRelays.exit.hostname, "se10-wireguard") + } + + func testDirectOnlyOnDaitaOnForMultihopWithoutDaitaRelay() throws { + let constraints = RelayConstraints( + entryLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])), + exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "got", "se10-wireguard")])) + ) + + let picker = SinglehopPicker( + relays: sampleRelays, + constraints: constraints, + connectionAttemptCount: 0, + daitaSettings: DAITASettings(daitaState: .on, directOnlyState: .on) + ) + + XCTAssertThrowsError(try picker.pick()) + } } diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift index 5b2a775a4f01..d15d18aab367 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift @@ -136,6 +136,39 @@ class RelaySelectorTests: XCTestCase { XCTAssertTrue(allPorts.contains(result.endpoint.ipv4Relay.port)) } + func testClosestRelay() throws { + let relayWithLocations = try sampleRelays.wireguard.relays.map { + let serverLocation = try XCTUnwrap(sampleRelays.locations[$0.location]) + let location = Location( + country: serverLocation.country, + countryCode: serverLocation.country, + city: serverLocation.city, + cityCode: serverLocation.city, + latitude: serverLocation.latitude, + longitude: serverLocation.longitude + ) + + return RelayWithLocation(relay: $0, serverLocation: location) + } + + let sampleLocation = try XCTUnwrap(sampleRelays.locations["se-got"]) + let location = Location( + country: "Sweden", + countryCode: sampleLocation.country, + city: "Gothenburg", + cityCode: sampleLocation.city, + latitude: sampleLocation.latitude, + longitude: sampleLocation.longitude + ) + + let selectedRelay = RelaySelector.WireGuard.closestRelay( + to: location, + using: relayWithLocations + ) + + XCTAssertEqual(selectedRelay?.hostname, "se10-wireguard") + } + func testClosestShadowsocksRelay() throws { let constraints = RelayConstraints( exitLocations: .only(UserSelectedRelays(locations: [.city("se", "sto")])) diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift index 2bc33ca86eea..b8ad4d2d4629 100644 --- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift +++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift @@ -80,13 +80,13 @@ class RelaySelectorWrapperTests: XCTestCase { XCTAssertNoThrow(try wrapper.selectRelays(tunnelSettings: settings, connectionAttemptCount: 0)) } - func testCannotSelectRelayWithMultihopOnAndDaitaOn() throws { + func testCannotSelectRelayWithMultihopOnDaitaOnDirectOnlyOn() throws { let wrapper = RelaySelectorWrapper(relayCache: relayCache) let settings = LatestTunnelSettings( relayConstraints: multihopWithoutDaitaConstraints, tunnelMultihopState: .on, - daita: DAITASettings(daitaState: .on) + daita: DAITASettings(daitaState: .on, directOnlyState: .on) ) XCTAssertThrowsError(try wrapper.selectRelays(tunnelSettings: settings, connectionAttemptCount: 0)) @@ -107,7 +107,7 @@ class RelaySelectorWrapperTests: XCTestCase { // 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 { + func testCanSelectRelayWithMultihopOffDaitaOnThroughMultihop() throws { let wrapper = RelaySelectorWrapper(relayCache: relayCache) let settings = LatestTunnelSettings( diff --git a/ios/MullvadVPNTests/MullvadSettings/DAITASettingsTests.swift b/ios/MullvadVPNTests/MullvadSettings/DAITASettingsTests.swift new file mode 100644 index 000000000000..f153de9a55b0 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadSettings/DAITASettingsTests.swift @@ -0,0 +1,21 @@ +// +// DAITASettingsTests.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-09-27. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadSettings +import XCTest + +final class DAITASettingsTests: XCTestCase { + func testShouldDoDirectOnly() throws { + let settings = DAITASettings() + + XCTAssertEqual( + settings.shouldDoAutomaticRouting, + settings.daitaState == .on && settings.directOnlyState == .off + ) + } +}