diff options
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 18 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 22 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/CoordinatesTests.swift | 63 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/RelaySelectorTests.swift | 67 | ||||
| -rw-r--r-- | ios/RelaySelector/Haversine.swift | 48 | ||||
| -rw-r--r-- | ios/RelaySelector/Midpoint.swift | 49 | ||||
| -rw-r--r-- | ios/RelaySelector/RelaySelector.swift | 77 |
7 files changed, 301 insertions, 43 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 0b41c08aba..081f4dfa93 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -418,8 +418,10 @@ A9D99BA52A1F808900DE27D3 /* RelayCache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 063F02732902B63F001FA09F /* RelayCache.framework */; }; A9D99BA62A1F809C00DE27D3 /* libRelaySelector.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5898D29829017DAC00EB5EBA /* libRelaySelector.a */; }; A9D99BA92A1F81B700DE27D3 /* MullvadTransport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; }; - A9EC20EF2A5D79ED0040D56E /* TunnelObfuscation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5840231F2A406BF5007B27AC /* TunnelObfuscation.framework */; }; A9EC20F02A5D79ED0040D56E /* TunnelObfuscation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5840231F2A406BF5007B27AC /* TunnelObfuscation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A9EC20E62A5C488D0040D56E /* Haversine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EC20E52A5C488D0040D56E /* Haversine.swift */; }; + A9EC20E82A5D3A8C0040D56E /* CoordinatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */; }; + A9EC20F42A5D96030040D56E /* Midpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EC20F32A5D96030040D56E /* Midpoint.swift */; }; E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */; }; E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; }; E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; }; @@ -1193,6 +1195,9 @@ A9A8A8EA2A262AB30086D569 /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = "<group>"; }; A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCacheTests.swift; sourceTree = "<group>"; }; A9D99B9F2A1F7F3A00DE27D3 /* TransportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportProvider.swift; sourceTree = "<group>"; }; + A9EC20E52A5C488D0040D56E /* Haversine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Haversine.swift; sourceTree = "<group>"; }; + A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatesTests.swift; sourceTree = "<group>"; }; + A9EC20F32A5D96030040D56E /* Midpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Midpoint.swift; sourceTree = "<group>"; }; E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeViewController.swift; sourceTree = "<group>"; }; E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; }; E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = "<group>"; }; @@ -1292,7 +1297,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A9EC20EF2A5D79ED0040D56E /* TunnelObfuscation.framework in Frameworks */, 58F0974E2A20C31100DA2DAD /* WireGuardKitTypes in Frameworks */, 5898D2A92901844E00EB5EBA /* libRelaySelector.a in Frameworks */, 58D223F9294C8FF00029F5F8 /* MullvadLogging.framework in Frameworks */, @@ -1965,6 +1969,8 @@ 5898D29929017DAC00EB5EBA /* RelaySelector */ = { isa = PBXGroup; children = ( + A9EC20E52A5C488D0040D56E /* Haversine.swift */, + A9EC20F32A5D96030040D56E /* Midpoint.swift */, 58781CD422AFBA39009B9D8E /* RelaySelector.swift */, ); path = RelaySelector; @@ -2004,17 +2010,18 @@ isa = PBXGroup; children = ( 58B0A2A4238EE67E00BC001D /* Info.plist */, - F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */, + A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */, 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */, 58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */, 58C3FA672A385C89006A450A /* FileCacheTests.swift */, 582A8A3928BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift */, + F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + 58C3FA652A38549D006A450A /* MockFileCache.swift */, A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */, 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, 5807E2C1243203D000F5FF30 /* StringTests.swift */, 58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */, - 58C3FA652A38549D006A450A /* MockFileCache.swift */, ); path = MullvadVPNTests; sourceTree = "<group>"; @@ -3129,6 +3136,8 @@ buildActionMask = 2147483647; files = ( 5898D29F29017DD000EB5EBA /* RelaySelector.swift in Sources */, + A9EC20E62A5C488D0040D56E /* Haversine.swift in Sources */, + A9EC20F42A5D96030040D56E /* Midpoint.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3166,6 +3175,7 @@ 5807E2C3243203E700F5FF30 /* String+Split.swift in Sources */, 580810E92A30E17300B74552 /* DeviceCheckRemoteServiceProtocol.swift in Sources */, F07BF2582A26112D00042943 /* InputTextFormatterTests.swift in Sources */, + A9EC20E82A5D3A8C0040D56E /* CoordinatesTests.swift in Sources */, 58B0A2A8238EE68200BC001D /* RelaySelectorTests.swift in Sources */, A9467E802A29E0A6000DC21F /* AddressCacheTests.swift in Sources */, ); diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 02691892fe..0000000000 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,22 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "173f567a2dfec11d74588eea82cecea555bdc0bc", - "version" : "1.4.0" - } - }, - { - "identity" : "wireguard-apple", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mullvad/wireguard-apple.git", - "state" : { - "revision" : "11a00c20dc03f2751db47e94f585c0778c7bde82" - } - } - ], - "version" : 2 -} diff --git a/ios/MullvadVPNTests/CoordinatesTests.swift b/ios/MullvadVPNTests/CoordinatesTests.swift new file mode 100644 index 0000000000..6466537ca2 --- /dev/null +++ b/ios/MullvadVPNTests/CoordinatesTests.swift @@ -0,0 +1,63 @@ +// +// CoordinatesTests.swift +// MullvadVPNTests +// +// Created by Marco Nikic on 2023-07-11. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import CoreLocation +@testable import RelaySelector +import XCTest + +final class CoordinatesTests: XCTestCase { + func testHaversine() { + let distance1 = Haversine.distance(36.12, -86.67, 33.94, -118.4) + XCTAssertEqual(2887.259_950_607_108_7, distance1) + + let distance2 = Haversine.distance(90.0, 5.0, 90.0, 79.0) + XCTAssertEqual(0.0000000000004696822692507987, distance2) + + let distance3 = Haversine.distance(0, 0, 0, 0) + XCTAssertEqual(0, distance3) + + let distance4 = Haversine.distance(49.0, 12.0, 49.0, 12.0) + XCTAssertEqual(0, distance4) + + let distance5 = Haversine.distance(6.0, 27.0, 7.0, 27.0) + XCTAssertEqual(111.226_342_571_094_7, distance5) + + let distance6 = Haversine.distance(0.0, 179.5, 0.0, -179.5) + XCTAssertEqual(111.226_342_571_100_6, distance6) + } + + func testMidpoint() { + let midpoint1 = Midpoint.location( + in: [ + CLLocationCoordinate2D(latitude: 0, longitude: 90), + CLLocationCoordinate2D(latitude: 90, longitude: 0), + ] + ) + + let midpoint2 = Midpoint.location( + in: [ + CLLocationCoordinate2D(latitude: -20, longitude: 90), + CLLocationCoordinate2D(latitude: -20, longitude: -90), + ] + ) + + let expectedMidpoint1Value = CLLocationCoordinate2D(latitude: 45, longitude: 90) + XCTAssertEqual(expectedMidpoint1Value.latitude, midpoint1.latitude, accuracy: 0.1) + XCTAssertEqual(expectedMidpoint1Value.longitude, midpoint1.longitude, accuracy: 0.1) + + let expectedMidpoint2Value = CLLocationCoordinate2D(latitude: -90, longitude: 0) + XCTAssertEqual(expectedMidpoint2Value.latitude, midpoint2.latitude, accuracy: 0.1) + XCTAssertEqual(expectedMidpoint2Value.longitude, midpoint2.longitude, accuracy: 0.1) + } +} + +extension CLLocationCoordinate2D: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude + } +} diff --git a/ios/MullvadVPNTests/RelaySelectorTests.swift b/ios/MullvadVPNTests/RelaySelectorTests.swift index 0e97d09eff..fa8fc6659b 100644 --- a/ios/MullvadVPNTests/RelaySelectorTests.swift +++ b/ios/MullvadVPNTests/RelaySelectorTests.swift @@ -94,6 +94,17 @@ class RelaySelectorTests: XCTestCase { XCTAssertEqual(selectedRelay?.hostname, "se-sto-br-001") } + + func testClosestShadowsocksRelayIsRandomWhenNoContraintsAreSatisfied() throws { + let constraints = RelayConstraints(location: .only(.country("INVALID COUNTRY"))) + + let selectedRelay = try XCTUnwrap(RelaySelector.closestShadowsocksRelayConstrained( + by: constraints, + in: sampleRelays + )) + + XCTAssertTrue(sampleRelays.bridge.relays.contains(selectedRelay)) + } } private let sampleRelays = REST.ServerRelaysResponse( @@ -134,6 +145,18 @@ private let sampleRelays = REST.ServerRelaysResponse( latitude: 43.666667, longitude: -79.416667 ), + "us-atl": REST.ServerLocation( + country: "USA", + city: "Atlanta, GA", + latitude: 40.73061, + longitude: -73.935242 + ), + "us-dal": REST.ServerLocation( + country: "USA", + city: "Dallas, TX", + latitude: 32.89748, + longitude: -97.040443 + ), ], wireguard: REST.ServerWireguardTunnels( ipv4Gateway: .loopback, @@ -188,6 +211,30 @@ private let sampleRelays = REST.ServerRelaysResponse( publicKey: Data(), includeInCountry: true ), + REST.ServerRelay( + hostname: "us-dal-wg-001", + active: true, + owned: true, + location: "us-dal", + provider: "", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: Data(), + includeInCountry: true + ), + REST.ServerRelay( + hostname: "us-nyc-wg-301", + active: true, + owned: true, + location: "us-nyc", + provider: "", + weight: 100, + ipv4AddrIn: .loopback, + ipv6AddrIn: .loopback, + publicKey: Data(), + includeInCountry: true + ), ] ), bridge: REST.ServerBridges(shadowsocks: [ @@ -233,5 +280,25 @@ private let sampleRelays = REST.ServerRelaysResponse( weight: 100, includeInCountry: true ), + REST.BridgeRelay( + hostname: "us-atl-br-101", + active: true, + owned: false, + location: "us-atl", + provider: "100TB", + ipv4AddrIn: .loopback, + weight: 100, + includeInCountry: true + ), + REST.BridgeRelay( + hostname: "us-dal-br-101", + active: true, + owned: false, + location: "us-dal", + provider: "100TB", + ipv4AddrIn: .loopback, + weight: 100, + includeInCountry: true + ), ]) ) diff --git a/ios/RelaySelector/Haversine.swift b/ios/RelaySelector/Haversine.swift new file mode 100644 index 0000000000..946b3ca2c3 --- /dev/null +++ b/ios/RelaySelector/Haversine.swift @@ -0,0 +1,48 @@ +// +// Haversine.swift +// RelaySelector +// +// Created by Marco Nikic on 2023-06-29. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +public enum Haversine { + /// Approximation of the radius of the average circumference, + /// where the boundaries are the meridian (6367.45 km) and the equator (6378.14 km). + static let earthRadiusInKm = 6372.8 + + /// Implemented as per https://rosettacode.org/wiki/Haversine_formula#Swift + /// Computes the great circle distance between two points on a sphere. + /// + /// The inputs are converted to radians, and the output is in kilometers. + /// - Parameters: + /// - lat1: The first point's latitude + /// - lon1: The first point's longitude + /// - lat2: The second point's latitude + /// - lon2: The second point's longitude + /// - Returns: The haversine distance between the two points. + static func distance( + _ latitude1: Double, + _ longitude1: Double, + _ latitude2: Double, + _ longitude2: Double + ) -> Double { + let dLat = latitude1.toRadians - latitude2.toRadians + let dLon = longitude1.toRadians - longitude2.toRadians + + let haversine = sin(dLat / 2).squared + sin(dLon / 2) + .squared * cos(latitude1.toRadians) * cos(latitude2.toRadians) + let c = 2 * asin(sqrt(haversine)) + + return Self.earthRadiusInKm * c + } +} + +extension Double { + var toRadians: Double { self * Double.pi / 180.0 } + var toDegrees: Double { self * 180.0 / Double.pi } + var squared: Double { pow(self, 2.0) } +} diff --git a/ios/RelaySelector/Midpoint.swift b/ios/RelaySelector/Midpoint.swift new file mode 100644 index 0000000000..d01983a96f --- /dev/null +++ b/ios/RelaySelector/Midpoint.swift @@ -0,0 +1,49 @@ +// +// Midpoint.swift +// RelaySelector +// +// Created by Marco Nikic on 2023-07-11. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import CoreLocation +import Foundation +import MullvadTypes + +public enum Midpoint { + /// Computes the approximate midpoint of a set of locations. + /// + /// This works by calculating the mean Cartesian coordinates, and converting them + /// back to spherical coordinates. This is approximate, because the semi-minor (polar) + /// axis is assumed to equal the semi-major (equatorial) axis. + /// + /// https://en.wikipedia.org/wiki/Spherical_coordinate_system#Cartesian_coordinates + static func location(in coordinates: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D { + var x = 0.0, y = 0.0, z = 0.0 + var count = 0 + + coordinates.forEach { coordinate in + let cos_lat = cos(coordinate.latitude.toRadians) + let sin_lat = sin(coordinate.latitude.toRadians) + let cos_lon = cos(coordinate.longitude.toRadians) + let sin_lon = sin(coordinate.longitude.toRadians) + + x += cos_lat * cos_lon + y += cos_lat * sin_lon + z += sin_lat + + count += 1 + } + + let inv_total_weight = 1.0 / Double(count) + x *= inv_total_weight + y *= inv_total_weight + z *= inv_total_weight + + let longitude = atan2(y, x) + let hypotenuse = sqrt(x * x + y * y) + let latitude = atan2(z, hypotenuse) + + return CLLocationCoordinate2D(latitude: latitude.toDegrees, longitude: longitude.toDegrees) + } +} diff --git a/ios/RelaySelector/RelaySelector.swift b/ios/RelaySelector/RelaySelector.swift index 5643f5d73f..558c81452c 100644 --- a/ios/RelaySelector/RelaySelector.swift +++ b/ios/RelaySelector/RelaySelector.swift @@ -42,9 +42,43 @@ public enum RelaySelector { ) -> REST.BridgeRelay? { let mappedBridges = mapRelays(relays: relaysResponse.bridge.relays, locations: relaysResponse.locations) let filteredRelays = applyConstraints(constraints, relays: mappedBridges) - let randomBridgeRelay = pickRandomRelay(relays: filteredRelays) + guard filteredRelays.isEmpty == false else { return shadowsocksRelay(from: relaysResponse) } - return randomBridgeRelay?.relay ?? 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 } /** @@ -64,7 +98,7 @@ public enum RelaySelector { numberOfFailedAttempts: numberOfFailedAttempts ) - guard let relayWithLocation = pickRandomRelay(relays: filteredRelays), let port else { + guard let relayWithLocation = pickRandomRelayByWeight(relays: filteredRelays), let port else { throw NoRelaysSatisfyingConstraintsError() } @@ -82,7 +116,7 @@ public enum RelaySelector { return RelaySelectorResult( endpoint: endpoint, relay: relayWithLocation.relay, - location: relayWithLocation.location + location: relayWithLocation.serverLocation ) } @@ -98,16 +132,16 @@ public enum RelaySelector { case let .only(relayConstraint): switch relayConstraint { case let .country(countryCode): - return relayWithLocation.location.countryCode == countryCode && + return relayWithLocation.serverLocation.countryCode == countryCode && relayWithLocation.relay.includeInCountry case let .city(countryCode, cityCode): - return relayWithLocation.location.countryCode == countryCode && - relayWithLocation.location.cityCode == cityCode + return relayWithLocation.serverLocation.countryCode == countryCode && + relayWithLocation.serverLocation.cityCode == cityCode case let .hostname(countryCode, cityCode, hostname): - return relayWithLocation.location.countryCode == countryCode && - relayWithLocation.location.cityCode == cityCode && + return relayWithLocation.serverLocation.countryCode == countryCode && + relayWithLocation.serverLocation.cityCode == cityCode && relayWithLocation.relay.hostname == hostname } } @@ -136,11 +170,15 @@ public enum RelaySelector { } } - private static func pickRandomRelay<T: AnyRelay>(relays: [RelayWithLocation<T>]) -> RelayWithLocation<T>? { - let totalWeight = relays.reduce(0) { accummulatedWeight, relayWithLocation in - accummulatedWeight + relayWithLocation.relay.weight - } + private static func pickRandomRelayByWeight<T: AnyRelay>(relays: [RelayWithLocation<T>]) + -> RelayWithLocation<T>? { + rouletteSelection(relays: relays, weightFunction: { relayWithLocation in relayWithLocation.relay.weight }) + } + private static func rouletteSelection<T>(relays: [T], weightFunction: (T) -> UInt64) -> T? { + let totalWeight = relays.map { weightFunction($0) }.reduce(0) { accumulated, weight in + accumulated + weight + } // Return random relay when all relays within the list have zero weight. guard totalWeight > 0 else { return relays.randomElement() @@ -150,9 +188,9 @@ public enum RelaySelector { // non-zero weight. var i = (1 ... totalWeight).randomElement()! - let randomRelay = relays.first { relayWithLocation -> Bool in + let randomRelay = relays.first { relay -> Bool in let (result, isOverflow) = i - .subtractingReportingOverflow(relayWithLocation.relay.weight) + .subtractingReportingOverflow(weightFunction(relay)) i = isOverflow ? 0 : result @@ -228,7 +266,7 @@ public enum RelaySelector { longitude: serverLocation.longitude ) - return RelayWithLocation(relay: relay, location: location) + return RelayWithLocation(relay: relay, serverLocation: location) } } @@ -266,5 +304,10 @@ extension REST.BridgeRelay: AnyRelay {} fileprivate struct RelayWithLocation<T: AnyRelay> { let relay: T - let location: Location + let serverLocation: Location +} + +fileprivate struct RelayWithDistance<T: AnyRelay> { + let relay: T + let distance: Double } |
