From 5a0554fc315c31f2aa7955bf0ef9fdc9b1acff5e Mon Sep 17 00:00:00 2001
From: Jon Petersson <jon.petersson@kvadrat.se>
Date: Mon, 10 Jun 2024 16:14:39 +0200
Subject: [PATCH] Add general support for multiple selected relays

---
 .../MullvadREST/RelaySelectorStub.swift       |   2 +-
 .../Relay/RelaySelector+Wireguard.swift       |   4 +-
 .../SimulatorTunnelProviderHost.swift         |  32 +++---
 .../MapConnectionStatusOperation.swift        |   8 +-
 .../TunnelManager/StartTunnelOperation.swift  |   8 +-
 .../TunnelManager/Tunnel+Messaging.swift      |   6 +-
 .../TunnelManager/TunnelInteractor.swift      |   2 +-
 .../TunnelManager/TunnelManager.swift         |   8 +-
 .../TunnelManager/TunnelState+UI.swift        |  12 +-
 .../TunnelManager/TunnelState.swift           |  40 +++----
 .../Tunnel/TunnelControlView.swift            |  14 +--
 .../Tunnel/TunnelControlViewModel.swift       |   4 +-
 .../Tunnel/TunnelViewController.swift         |  12 +-
 .../PacketTunnelActorReducerTests.swift       |  13 +--
 .../TunnelManager/MockTunnelInteractor.swift  |  14 +--
 .../PacketTunnelProvider.swift                |   4 +-
 .../Actor/ObservedState.swift                 |   8 +-
 .../Actor/PacketTunnelActor+PostQuantum.swift |  14 +--
 .../Actor/PacketTunnelActor+Public.swift      |   6 +-
 .../Actor/PacketTunnelActor.swift             | 106 +++++++++---------
 .../Actor/PacketTunnelActorCommand.swift      |  10 +-
 .../Actor/PacketTunnelActorProtocol.swift     |   2 +-
 .../Actor/PacketTunnelActorReducer.swift      |  14 +--
 ios/PacketTunnelCore/Actor/StartOptions.swift |  12 +-
 .../Actor/State+Extensions.swift              |   2 +-
 ios/PacketTunnelCore/Actor/State.swift        |  14 +--
 .../IPC/PacketTunnelOptions.swift             |  12 +-
 .../IPC/TunnelProviderMessage.swift           |   2 +-
 .../AppMessageHandlerTests.swift              |  15 ++-
 .../EventChannelTests.swift                   |   2 +-
 .../Mocks/PacketTunnelActorStub.swift         |   2 +-
 31 files changed, 203 insertions(+), 201 deletions(-)

diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
index 2fe06dafe3dd..f2f8952df4f0 100644
--- a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
+++ b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
@@ -6,8 +6,8 @@
 //  Copyright © 2023 Mullvad VPN AB. All rights reserved.
 //
 
-import MullvadTypes
 import MullvadREST
+import MullvadTypes
 import WireGuardKitTypes
 
 /// Relay selector stub that accepts a block that can be used to provide custom implementation.
diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift
index 382479ee2067..1e611569417c 100644
--- a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift
+++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift
@@ -10,7 +10,7 @@ import MullvadTypes
 
 extension RelaySelector {
     public enum WireGuard {
-        /// Filters relay list using given constraints and selects random relay for exit relay.
+        /// Filters relay list using given constraints.
         public static func findCandidates(
             by relayConstraint: RelayConstraint<UserSelectedRelays>,
             in relays: REST.ServerRelaysResponse,
@@ -25,7 +25,7 @@ extension RelaySelector {
             )
         }
 
-        // TODO: Add comment.
+        /// Picks a random relay from a list.
         public static func pickCandidate(
             from relayWithLocations: [RelayWithLocation<REST.ServerRelay>],
             relays: REST.ServerRelaysResponse,
diff --git a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
index bc3ce91307aa..08b49d4b0585 100644
--- a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
+++ b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
@@ -18,7 +18,7 @@ import PacketTunnelCore
 
 final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
     private var observedState: ObservedState = .disconnected
-    private var selectedRelay: SelectedRelay?
+    private var selectedRelays: SelectedRelays?
     private let urlRequestProxy: URLRequestProxy
     private let relaySelector: RelaySelectorProtocol
 
@@ -43,12 +43,12 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
                 return
             }
 
-            var selectedRelay: SelectedRelay?
+            var selectedRelays: SelectedRelays?
 
             do {
                 let tunnelOptions = PacketTunnelOptions(rawOptions: options ?? [:])
 
-                selectedRelay = try tunnelOptions.getSelectedRelay()
+                selectedRelays = try tunnelOptions.getSelectedRelays()
             } catch {
                 providerLogger.error(
                     error: error,
@@ -60,7 +60,7 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
             }
 
             do {
-                setInternalStateConnected(with: try selectedRelay ?? pickRelay())
+                setInternalStateConnected(with: try selectedRelays ?? pickRelays())
                 completionHandler(nil)
             } catch {
                 providerLogger.error(
@@ -74,7 +74,7 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
 
     override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
         dispatchQueue.async { [weak self] in
-            self?.selectedRelay = nil
+            self?.selectedRelays = nil
             self?.observedState = .disconnected
 
             completionHandler()
@@ -117,17 +117,17 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
             reasserting = true
 
             switch nextRelay {
-            case let .preSelected(selectedRelay):
-                self.selectedRelay = selectedRelay
+            case let .preSelected(selectedRelays):
+                self.selectedRelays = selectedRelays
             case .random:
-                if let nextRelay = try? pickRelay() {
-                    self.selectedRelay = nextRelay
+                if let nextRelays = try? pickRelays() {
+                    self.selectedRelays = nextRelays
                 }
             case .current:
                 break
             }
 
-            setInternalStateConnected(with: selectedRelay)
+            setInternalStateConnected(with: selectedRelays)
             reasserting = false
 
             completionHandler?(nil)
@@ -156,28 +156,28 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
         }
     }
 
-    private func pickRelay() throws -> SelectedRelay {
+    private func pickRelays() throws -> SelectedRelays {
         let tunnelSettings = try SettingsManager.readSettings()
 
         return try relaySelector.selectRelays(
             with: tunnelSettings.relayConstraints,
             connectionAttemptCount: 0
-        ).exit // TODO: Multihop
+        )
     }
 
-    private func setInternalStateConnected(with selectedRelay: SelectedRelay?) {
-        guard let selectedRelay = selectedRelay else { return }
+    private func setInternalStateConnected(with selectedRelays: SelectedRelays?) {
+        guard let selectedRelays = selectedRelays else { return }
 
         do {
             let settings = try SettingsManager.readSettings()
             observedState = .connected(
                 ObservedConnectionState(
-                    selectedRelay: selectedRelay,
+                    selectedRelays: selectedRelays,
                     relayConstraints: settings.relayConstraints,
                     networkReachability: .reachable,
                     connectionAttemptCount: 0,
                     transportLayer: .udp,
-                    remotePort: selectedRelay.endpoint.ipv4Relay.port,
+                    remotePort: selectedRelays.exit.endpoint.ipv4Relay.port, // TODO: Multihop
                     isPostQuantum: settings.tunnelQuantumResistance.isEnabled
                 )
             )
diff --git a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift b/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift
index 4957af7c52ec..b605b85b476d 100644
--- a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift
+++ b/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift
@@ -51,19 +51,19 @@ class MapConnectionStatusOperation: AsyncOperation {
                 switch observedState {
                 case let .connected(connectionState):
                     return connectionState.isNetworkReachable
-                        ? .connected(connectionState.selectedRelay, isPostQuantum: connectionState.isPostQuantum)
+                        ? .connected(connectionState.selectedRelays, isPostQuantum: connectionState.isPostQuantum)
                         : .waitingForConnectivity(.noConnection)
                 case let .connecting(connectionState):
                     return connectionState.isNetworkReachable
-                        ? .connecting(connectionState.selectedRelay, isPostQuantum: connectionState.isPostQuantum)
+                        ? .connecting(connectionState.selectedRelays, isPostQuantum: connectionState.isPostQuantum)
                         : .waitingForConnectivity(.noConnection)
                 case let .negotiatingPostQuantumKey(connectionState, privateKey):
                     return connectionState.isNetworkReachable
-                        ? .negotiatingPostQuantumKey(connectionState.selectedRelay, privateKey)
+                        ? .negotiatingPostQuantumKey(connectionState.selectedRelays, privateKey)
                         : .waitingForConnectivity(.noConnection)
                 case let .reconnecting(connectionState):
                     return connectionState.isNetworkReachable
-                        ? .reconnecting(connectionState.selectedRelay, isPostQuantum: connectionState.isPostQuantum)
+                        ? .reconnecting(connectionState.selectedRelays, isPostQuantum: connectionState.isPostQuantum)
                         : .waitingForConnectivity(.noConnection)
                 case let .error(blockedState):
                     return .error(blockedState.reason)
diff --git a/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift b/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift
index cd9e8b7a88c5..bb125db5abd1 100644
--- a/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift
+++ b/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift
@@ -72,12 +72,12 @@ class StartTunnelOperation: ResultOperation<Void> {
     }
 
     private func startTunnel(tunnel: any TunnelProtocol) throws {
-        let selectedRelay = try? interactor.selectRelay()
+        let selectedRelays = try? interactor.selectRelays()
         var tunnelOptions = PacketTunnelOptions()
 
         do {
-            if let selectedRelay {
-                try tunnelOptions.setSelectedRelay(selectedRelay)
+            if let selectedRelays {
+                try tunnelOptions.setSelectedRelays(selectedRelays)
             }
         } catch {
             logger.error(
@@ -91,7 +91,7 @@ class StartTunnelOperation: ResultOperation<Void> {
         interactor.updateTunnelStatus { tunnelStatus in
             tunnelStatus = TunnelStatus()
             tunnelStatus.state = .connecting(
-                selectedRelay,
+                selectedRelays,
                 isPostQuantum: interactor.settings.tunnelQuantumResistance.isEnabled
             )
         }
diff --git a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
index 5299a281cc8d..04a231c5c497 100644
--- a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
+++ b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
@@ -22,16 +22,16 @@ private let dispatchQueue = DispatchQueue(label: "Tunnel.dispatchQueue")
 private let proxyRequestTimeout = REST.defaultAPINetworkTimeout + 2
 
 extension TunnelProtocol {
-    /// Request packet tunnel process to reconnect the tunnel with the given relay.
+    /// Request packet tunnel process to reconnect the tunnel with the given relays.
     func reconnectTunnel(
-        to nextRelay: NextRelay,
+        to nextRelays: NextRelays,
         completionHandler: @escaping (Result<Void, Error>) -> Void
     ) -> Cancellable {
         let operation = SendTunnelProviderMessageOperation(
             dispatchQueue: dispatchQueue,
             application: .shared,
             tunnel: self,
-            message: .reconnectTunnel(nextRelay),
+            message: .reconnectTunnel(nextRelays),
             completionHandler: completionHandler
         )
 
diff --git a/ios/MullvadVPN/TunnelManager/TunnelInteractor.swift b/ios/MullvadVPN/TunnelManager/TunnelInteractor.swift
index ef76cd8b8509..3b6735bd396a 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelInteractor.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelInteractor.swift
@@ -39,5 +39,5 @@ protocol TunnelInteractor {
 
     func startTunnel()
     func prepareForVPNConfigurationDeletion()
-    func selectRelay() throws -> SelectedRelay
+    func selectRelays() throws -> SelectedRelays
 }
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index 4e4d44cd4c05..d6d4be4d0616 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -782,13 +782,13 @@ final class TunnelManager: StorePaymentObserver {
         updateTunnelStatus(tunnel?.status ?? .disconnected)
     }
 
-    fileprivate func selectRelay() throws -> SelectedRelay {
+    fileprivate func selectRelays() throws -> SelectedRelays {
         let retryAttempts = tunnelStatus.observedState.connectionState?.connectionAttemptCount ?? 0
 
         return try relaySelector.selectRelays(
             with: settings.relayConstraints,
             connectionAttemptCount: retryAttempts
-        ).exit // TODO: Multihop
+        )
     }
 
     fileprivate func prepareForVPNConfigurationDeletion() {
@@ -1260,8 +1260,8 @@ private struct TunnelInteractorProxy: TunnelInteractor {
         tunnelManager.prepareForVPNConfigurationDeletion()
     }
 
-    func selectRelay() throws -> SelectedRelay {
-        try tunnelManager.selectRelay()
+    func selectRelays() throws -> SelectedRelays {
+        try tunnelManager.selectRelays()
     }
 
     func handleRestError(_ error: Error) {
diff --git a/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift b/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift
index eefb1db415d4..3422c8602d88 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift
@@ -187,8 +187,8 @@ extension TunnelState {
                         value: "Quantum secure connection. Connected to %@, %@",
                         comment: ""
                     ),
-                    tunnelInfo.location.city,
-                    tunnelInfo.location.country
+                    tunnelInfo.exit.location.city, // TODO: Multihop
+                    tunnelInfo.exit.location.country // TODO: Multihop
                 )
             } else {
                 String(
@@ -198,8 +198,8 @@ extension TunnelState {
                         value: "Secure connection. Connected to %@, %@",
                         comment: ""
                     ),
-                    tunnelInfo.location.city,
-                    tunnelInfo.location.country
+                    tunnelInfo.exit.location.city, // TODO: Multihop
+                    tunnelInfo.exit.location.country // TODO: Multihop
                 )
             }
 
@@ -219,8 +219,8 @@ extension TunnelState {
                     value: "Reconnecting to %@, %@",
                     comment: ""
                 ),
-                tunnelInfo.location.city,
-                tunnelInfo.location.country
+                tunnelInfo.exit.location.city, // TODO: Multihop
+                tunnelInfo.exit.location.country // TODO: Multihop
             )
 
         case .waitingForConnectivity(.noConnection), .error:
diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift
index 43ae78e17d49..2c5a6109b678 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelState.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift
@@ -50,13 +50,13 @@ enum TunnelState: Equatable, CustomStringConvertible {
     case pendingReconnect
 
     /// Connecting the tunnel.
-    case connecting(SelectedRelay?, isPostQuantum: Bool)
+    case connecting(SelectedRelays?, isPostQuantum: Bool)
 
     /// Negotiating a key for post-quantum resistance
-    case negotiatingPostQuantumKey(SelectedRelay, PrivateKey)
+    case negotiatingPostQuantumKey(SelectedRelays, PrivateKey)
 
     /// Connected the tunnel
-    case connected(SelectedRelay, isPostQuantum: Bool)
+    case connected(SelectedRelays, isPostQuantum: Bool)
 
     /// Disconnecting the tunnel
     case disconnecting(ActionAfterDisconnect)
@@ -66,10 +66,10 @@ enum TunnelState: Equatable, CustomStringConvertible {
 
     /// Reconnecting the tunnel.
     /// Transition to this state happens when:
-    /// 1. Asking the running tunnel to reconnect to new relay via IPC.
-    /// 2. Tunnel attempts to reconnect to new relay as the current relay appears to be
+    /// 1. Asking the running tunnel to reconnect to new relays via IPC.
+    /// 2. Tunnel attempts to reconnect to new relays as the current relays appear to be
     ///    dysfunctional.
-    case reconnecting(SelectedRelay, isPostQuantum: Bool)
+    case reconnecting(SelectedRelays, isPostQuantum: Bool)
 
     /// Waiting for connectivity to come back up.
     case waitingForConnectivity(WaitingForConnectionReason)
@@ -81,26 +81,26 @@ enum TunnelState: Equatable, CustomStringConvertible {
         switch self {
         case .pendingReconnect:
             "pending reconnect after disconnect"
-        case let .connecting(tunnelRelay, isPostQuantum):
-            if let tunnelRelay {
-                "connecting \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelay.hostname)"
+        case let .connecting(tunnelRelays, isPostQuantum):
+            if let tunnelRelays {
+                "connecting \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelays.exit.hostname)" // TODO: Multihop
             } else {
                 "connecting\(isPostQuantum ? " (PQ)" : ""), fetching relay"
             }
-        case let .connected(tunnelRelay, isPostQuantum):
-            "connected \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelay.hostname)"
+        case let .connected(tunnelRelays, isPostQuantum):
+            "connected \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelays.exit.hostname)" // TODO: Multihop
         case let .disconnecting(actionAfterDisconnect):
             "disconnecting and then \(actionAfterDisconnect)"
         case .disconnected:
             "disconnected"
-        case let .reconnecting(tunnelRelay, isPostQuantum):
-            "reconnecting \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelay.hostname)"
+        case let .reconnecting(tunnelRelays, isPostQuantum):
+            "reconnecting \(isPostQuantum ? "(PQ) " : "")to \(tunnelRelays.exit.hostname)" // TODO: Multihop
         case .waitingForConnectivity:
             "waiting for connectivity"
         case let .error(blockedStateReason):
             "error state: \(blockedStateReason)"
-        case let .negotiatingPostQuantumKey(tunnelRelay, _):
-            "negotiating key with \(tunnelRelay.hostname)"
+        case let .negotiatingPostQuantumKey(tunnelRelays, _):
+            "negotiating key with \(tunnelRelays.exit.hostname)" // TODO: Multihop
         }
     }
 
@@ -114,12 +114,12 @@ enum TunnelState: Equatable, CustomStringConvertible {
         }
     }
 
-    var relay: SelectedRelay? {
+    var relays: SelectedRelays? {
         switch self {
-        case let .connected(relay, _), let .reconnecting(relay, _), let .negotiatingPostQuantumKey(relay, _):
-            relay
-        case let .connecting(relay, _):
-            relay
+        case let .connected(relays, _), let .reconnecting(relays, _), let .negotiatingPostQuantumKey(relays, _):
+            relays
+        case let .connecting(relays, _):
+            relays
         case .disconnecting, .disconnected, .waitingForConnectivity, .pendingReconnect, .error:
             nil
         }
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift
index 6ef103f93cd2..c627f85fa360 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift	
@@ -145,9 +145,9 @@ final class TunnelControlView: UIView {
         updateSecureLabel(tunnelState: tunnelState)
         updateActionButtons(tunnelState: tunnelState)
         if tunnelState.isSecured {
-            updateTunnelRelay(tunnelRelay: tunnelState.relay)
+            updateTunnelRelays(tunnelRelays: tunnelState.relays)
         } else {
-            updateTunnelRelay(tunnelRelay: nil)
+            updateTunnelRelays(tunnelRelays: nil)
         }
     }
 
@@ -224,17 +224,17 @@ final class TunnelControlView: UIView {
         connectButtonBlurView.isEnabled = shouldEnableButtons
     }
 
-    private func updateTunnelRelay(tunnelRelay: SelectedRelay?) {
-        if let tunnelRelay {
+    private func updateTunnelRelays(tunnelRelays: SelectedRelays?) {
+        if let tunnelRelays {
             cityLabel.attributedText = attributedStringForLocation(
-                string: tunnelRelay.location.city
+                string: tunnelRelays.exit.location.city // TODO: Multihop
             )
             countryLabel.attributedText = attributedStringForLocation(
-                string: tunnelRelay.location.country
+                string: tunnelRelays.exit.location.country // TODO: Multihop
             )
 
             connectionPanel.isHidden = false
-            connectionPanel.connectedRelayName = tunnelRelay.hostname
+            connectionPanel.connectedRelayName = tunnelRelays.exit.hostname // TODO: Multihop
         } else {
             countryLabel.attributedText = attributedStringForLocation(string: " ")
             cityLabel.attributedText = attributedStringForLocation(string: " ")
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlViewModel.swift
index 833583efd389..c0df319d2505 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlViewModel.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlViewModel.swift	
@@ -18,7 +18,7 @@ struct TunnelControlViewModel {
     let outgoingConnectionInfo: OutgoingConnectionInfo?
 
     var connectionPanel: ConnectionPanelData? {
-        guard let tunnelRelay = tunnelStatus.state.relay else {
+        guard let tunnelRelays = tunnelStatus.state.relays else {
             return nil
         }
 
@@ -29,7 +29,7 @@ struct TunnelControlViewModel {
         }
 
         return ConnectionPanelData(
-            inAddress: "\(tunnelRelay.endpoint.ipv4Relay.ip)\(portAndTransport)",
+            inAddress: "\(tunnelRelays.exit.endpoint.ipv4Relay.ip)\(portAndTransport)", // TODO: Multihop
             outAddress: outgoingConnectionInfo?.outAddress
         )
     }
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
index 36f853504768..b5d0dfab64bf 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift	
@@ -147,18 +147,18 @@ class TunnelViewController: UIViewController, RootContainment {
 
     private func updateMap(animated: Bool) {
         switch tunnelState {
-        case let .connecting(tunnelRelay, _):
+        case let .connecting(tunnelRelays, _):
             mapViewController.removeLocationMarker()
             contentView.setAnimatingActivity(true)
-            mapViewController.setCenter(tunnelRelay?.location.geoCoordinate, animated: animated)
+            mapViewController.setCenter(tunnelRelays?.exit.location.geoCoordinate, animated: animated) // TODO: Multihop
 
-        case let .reconnecting(tunnelRelay, _), let .negotiatingPostQuantumKey(tunnelRelay, _):
+        case let .reconnecting(tunnelRelays, _), let .negotiatingPostQuantumKey(tunnelRelays, _):
             mapViewController.removeLocationMarker()
             contentView.setAnimatingActivity(true)
-            mapViewController.setCenter(tunnelRelay.location.geoCoordinate, animated: animated)
+            mapViewController.setCenter(tunnelRelays.exit.location.geoCoordinate, animated: animated) // TODO: Multihop
 
-        case let .connected(tunnelRelay, _):
-            let center = tunnelRelay.location.geoCoordinate
+        case let .connected(tunnelRelays, _):
+            let center = tunnelRelays.exit.location.geoCoordinate // TODO: Multihop
             mapViewController.setCenter(center, animated: animated) {
                 self.contentView.setAnimatingActivity(false)
 
diff --git a/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift b/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift
index b538b910d8d2..6184756f4631 100644
--- a/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift
@@ -6,8 +6,8 @@
 //  Copyright © 2024 Mullvad VPN AB. All rights reserved.
 //
 
-import MullvadTypes
 import MullvadMockData
+import MullvadTypes
 @testable import PacketTunnelCore
 @testable import PacketTunnelCoreTests
 import WireGuardKitTypes
@@ -15,19 +15,18 @@ import XCTest
 
 final class PacketTunnelActorReducerTests: XCTestCase {
     // swiftlint:disable:next force_try
-    let selectedRelay = try! RelaySelectorStub
+    let selectedRelays = try! RelaySelectorStub
         .nonFallible()
         .selectRelays(with: RelayConstraints(), connectionAttemptCount: 0)
-        .exit // TODO: Multihop
 
     func makeConnectionData(keyPolicy: State.KeyPolicy = .useCurrent) -> State.ConnectionData {
         State.ConnectionData(
-            selectedRelay: selectedRelay,
+            selectedRelays: selectedRelays,
             relayConstraints: RelayConstraints(),
             keyPolicy: keyPolicy,
             networkReachability: .reachable,
             connectionAttemptCount: 0,
-            connectedEndpoint: selectedRelay.endpoint,
+            connectedEndpoint: selectedRelays.exit.endpoint, // TODO: Multihop
             transportLayer: .udp,
             remotePort: 12345,
             isPostQuantum: false
@@ -55,13 +54,13 @@ final class PacketTunnelActorReducerTests: XCTestCase {
         // When
         let effects = PacketTunnelActor.Reducer.reduce(
             &state,
-            .start(StartOptions(launchSource: .app, selectedRelay: selectedRelay))
+            .start(StartOptions(launchSource: .app, selectedRelays: selectedRelays))
         )
         // Then
         XCTAssertEqual(effects, [
             .startDefaultPathObserver,
             .startTunnelMonitor,
-            .startConnection(.preSelected(selectedRelay)),
+            .startConnection(.preSelected(selectedRelays)),
         ])
     }
 
diff --git a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/MockTunnelInteractor.swift b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/MockTunnelInteractor.swift
index 622c5269ad2e..3da521592243 100644
--- a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/MockTunnelInteractor.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/MockTunnelInteractor.swift
@@ -14,9 +14,9 @@ import MullvadSettings
 class MockTunnelInteractor: TunnelInteractor {
     var isConfigurationLoaded: Bool
 
-    var settings: MullvadSettings.LatestTunnelSettings
+    var settings: LatestTunnelSettings
 
-    var deviceState: MullvadSettings.DeviceState
+    var deviceState: DeviceState
 
     var onUpdateTunnelStatus: ((TunnelStatus) -> Void)?
 
@@ -24,8 +24,8 @@ class MockTunnelInteractor: TunnelInteractor {
 
     init(
         isConfigurationLoaded: Bool,
-        settings: MullvadSettings.LatestTunnelSettings,
-        deviceState: MullvadSettings.DeviceState,
+        settings: LatestTunnelSettings,
+        deviceState: DeviceState,
         onUpdateTunnelStatus: ((TunnelStatus) -> Void)? = nil
     ) {
         self.isConfigurationLoaded = isConfigurationLoaded
@@ -59,9 +59,9 @@ class MockTunnelInteractor: TunnelInteractor {
 
     func setConfigurationLoaded() {}
 
-    func setSettings(_ settings: MullvadSettings.LatestTunnelSettings, persist: Bool) {}
+    func setSettings(_ settings: LatestTunnelSettings, persist: Bool) {}
 
-    func setDeviceState(_ deviceState: MullvadSettings.DeviceState, persist: Bool) {}
+    func setDeviceState(_ deviceState: DeviceState, persist: Bool) {}
 
     func removeLastUsedAccount() {}
 
@@ -73,7 +73,7 @@ class MockTunnelInteractor: TunnelInteractor {
 
     struct NotImplementedError: Error {}
 
-    func selectRelay() throws -> SelectedRelay {
+    func selectRelays() throws -> SelectedRelays {
         throw NotImplementedError()
     }
 }
diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift
index ebefeee7868a..cf81a3b1b41a 100644
--- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift
+++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift
@@ -207,9 +207,9 @@ extension PacketTunnelProvider {
         var parsedOptions = StartOptions(launchSource: tunnelOptions.isOnDemand() ? .onDemand : .app)
 
         do {
-            if let selectedRelay = try tunnelOptions.getSelectedRelay() {
+            if let selectedRelays = try tunnelOptions.getSelectedRelays() {
                 parsedOptions.launchSource = .app
-                parsedOptions.selectedRelay = selectedRelay
+                parsedOptions.selectedRelays = selectedRelays
             } else if !tunnelOptions.isOnDemand() {
                 parsedOptions.launchSource = .system
             }
diff --git a/ios/PacketTunnelCore/Actor/ObservedState.swift b/ios/PacketTunnelCore/Actor/ObservedState.swift
index 6975191e0497..43d99fdfea6b 100644
--- a/ios/PacketTunnelCore/Actor/ObservedState.swift
+++ b/ios/PacketTunnelCore/Actor/ObservedState.swift
@@ -27,7 +27,7 @@ public enum ObservedState: Equatable, Codable {
 
 /// A serializable representation of internal connection state.
 public struct ObservedConnectionState: Equatable, Codable {
-    public var selectedRelay: SelectedRelay
+    public var selectedRelays: SelectedRelays
     public var relayConstraints: RelayConstraints
     public var networkReachability: NetworkReachability
     public var connectionAttemptCount: UInt
@@ -41,7 +41,7 @@ public struct ObservedConnectionState: Equatable, Codable {
     }
 
     public init(
-        selectedRelay: SelectedRelay,
+        selectedRelays: SelectedRelays,
         relayConstraints: RelayConstraints,
         networkReachability: NetworkReachability,
         connectionAttemptCount: UInt,
@@ -50,7 +50,7 @@ public struct ObservedConnectionState: Equatable, Codable {
         lastKeyRotation: Date? = nil,
         isPostQuantum: Bool
     ) {
-        self.selectedRelay = selectedRelay
+        self.selectedRelays = selectedRelays
         self.relayConstraints = relayConstraints
         self.networkReachability = networkReachability
         self.connectionAttemptCount = connectionAttemptCount
@@ -95,7 +95,7 @@ extension State.ConnectionData {
     /// Map `State.ConnectionData` to `ObservedConnectionState`.
     var observedConnectionState: ObservedConnectionState {
         ObservedConnectionState(
-            selectedRelay: selectedRelay,
+            selectedRelays: selectedRelays,
             relayConstraints: relayConstraints,
             networkReachability: networkReachability,
             connectionAttemptCount: connectionAttemptCount,
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor+PostQuantum.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor+PostQuantum.swift
index d8a5a8772c50..30348546f173 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActor+PostQuantum.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor+PostQuantum.swift
@@ -16,10 +16,10 @@ extension PacketTunnelActor {
      */
     internal func tryStartPostQuantumNegotiation(
         withSettings settings: Settings,
-        nextRelay: NextRelay,
+        nextRelays: NextRelays,
         reason: ActorReconnectReason
     ) async throws {
-        if let connectionState = try obfuscateConnection(nextRelay: nextRelay, settings: settings, reason: reason) {
+        if let connectionState = try obfuscateConnection(nextRelays: nextRelays, settings: settings, reason: reason) {
             let selectedEndpoint = connectionState.connectedEndpoint
             let activeKey = activeKey(from: connectionState, in: settings)
 
@@ -44,18 +44,18 @@ extension PacketTunnelActor {
     internal func postQuantumConnect(with key: PreSharedKey, privateKey: PrivateKey) async {
         guard
             // It is important to select the same relay that was saved in the connection state as the key negotiation happened with this specific relay.
-            let selectedRelay = state.connectionData?.selectedRelay,
+            let selectedRelays = state.connectionData?.selectedRelays,
             let settings: Settings = try? settingsReader.read(),
             let connectionState = try? obfuscateConnection(
-                nextRelay: .preSelected(selectedRelay),
+                nextRelays: .preSelected(selectedRelays),
                 settings: settings,
                 reason: .userInitiated
             )
         else {
             logger.error("Could not create connection state in PostQuantumConnect")
 
-            let nextRelay: NextRelay = (state.connectionData?.selectedRelay).map { .preSelected($0) } ?? .current
-            eventChannel.send(.reconnect(nextRelay))
+            let nextRelays: NextRelays = (state.connectionData?.selectedRelays).map { .preSelected($0) } ?? .current
+            eventChannel.send(.reconnect(nextRelays))
             return
         }
 
@@ -76,7 +76,7 @@ extension PacketTunnelActor {
 
         try? await tunnelAdapter.start(configuration: configurationBuilder.makeConfiguration())
         // Resume tunnel monitoring and use IPv4 gateway as a probe address.
-        tunnelMonitor.start(probeAddress: connectionState.selectedRelay.endpoint.ipv4Gateway)
+        tunnelMonitor.start(probeAddress: connectionState.selectedRelays.exit.endpoint.ipv4Gateway) // TODO: Multihop
         // Restart default path observer and notify the observer with the current path that might have changed while
         // path observer was paused.
         startDefaultPathObserver(notifyObserverWithCurrentPath: false)
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor+Public.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor+Public.swift
index 5ba42a0bd203..160fd9bbe6a3 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActor+Public.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor+Public.swift
@@ -36,10 +36,10 @@ extension PacketTunnelActor {
     /**
      Tell actor to reconnect the tunnel.
 
-     - Parameter nextRelay: next relay to connect to.
+     - Parameter nextRelays: next relays to connect to.
      */
-    public nonisolated func reconnect(to nextRelay: NextRelay, reconnectReason: ActorReconnectReason) {
-        eventChannel.send(.reconnect(nextRelay, reason: reconnectReason))
+    public nonisolated func reconnect(to nextRelays: NextRelays, reconnectReason: ActorReconnectReason) {
+        eventChannel.send(.reconnect(nextRelays, reason: reconnectReason))
     }
 
     /**
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift
index f3bc9cdc5a35..715cfd840dc3 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift
@@ -110,16 +110,16 @@ public actor PacketTunnelActor {
             tunnelMonitor.stop()
         case let .updateTunnelMonitorPath(networkPath):
             handleDefaultPathChange(networkPath)
-        case let .startConnection(nextRelay):
+        case let .startConnection(nextRelays):
             do {
-                try await tryStart(nextRelay: nextRelay)
+                try await tryStart(nextRelays: nextRelays)
             } catch {
                 logger.error(error: error, message: "Failed to start the tunnel.")
                 await setErrorStateInternal(with: error)
             }
-        case let .restartConnection(nextRelay, reason):
+        case let .restartConnection(nextRelays, reason):
             do {
-                try await tryStart(nextRelay: nextRelay, reason: reason)
+                try await tryStart(nextRelays: nextRelays, reason: reason)
             } catch {
                 logger.error(error: error, message: "Failed to reconnect the tunnel.")
                 await setErrorStateInternal(with: error)
@@ -168,7 +168,7 @@ extension PacketTunnelActor {
         setTunnelMonitorEventHandler()
 
         do {
-            try await tryStart(nextRelay: options.selectedRelay.map { .preSelected($0) } ?? .random)
+            try await tryStart(nextRelays: options.selectedRelays.map { .preSelected($0) } ?? .random)
         } catch {
             logger.error(error: error, message: "Failed to start the tunnel.")
 
@@ -206,13 +206,13 @@ extension PacketTunnelActor {
     }
 
     /**
-     Reconnect tunnel to new relay. Enters error state on failure.
+     Reconnect tunnel to new relays. Enters error state on failure.
 
      - Parameters:
-         - nextRelay: next relay to connect to
+         - nextRelay: next relays to connect to
          - reason: reason for reconnect
      */
-    private func reconnect(to nextRelay: NextRelay, reason: ActorReconnectReason) async {
+    private func reconnect(to nextRelays: NextRelays, reason: ActorReconnectReason) async {
         do {
             switch state {
             // There is no connection monitoring going on when exchanging keys.
@@ -227,7 +227,7 @@ extension PacketTunnelActor {
                     tunnelMonitor.stop()
                 }
 
-                try await tryStart(nextRelay: nextRelay, reason: reason)
+                try await tryStart(nextRelays: nextRelays, reason: reason)
 
             case .disconnected, .disconnecting, .initial:
                 break
@@ -246,15 +246,15 @@ extension PacketTunnelActor {
      - Start either a direct connection or the post-quantum key negotiation process, depending on settings.
      */
     private func tryStart(
-        nextRelay: NextRelay,
+        nextRelays: NextRelays,
         reason: ActorReconnectReason = .userInitiated
     ) async throws {
         let settings: Settings = try settingsReader.read()
 
         if settings.quantumResistance.isEnabled {
-            try await tryStartPostQuantumNegotiation(withSettings: settings, nextRelay: nextRelay, reason: reason)
+            try await tryStartPostQuantumNegotiation(withSettings: settings, nextRelays: nextRelays, reason: reason)
         } else {
-            try await tryStartConnection(withSettings: settings, nextRelay: nextRelay, reason: reason)
+            try await tryStartConnection(withSettings: settings, nextRelays: nextRelays, reason: reason)
         }
     }
 
@@ -269,15 +269,15 @@ extension PacketTunnelActor {
      - Reactivate default path observation (disabled when configuring tunnel adapter)
 
      - Parameters:
-         - nextRelay: which relay should be selected next.
+         - nextRelays: which relays should be selected next.
          - reason: reason for reconnect
      */
     private func tryStartConnection(
         withSettings settings: Settings,
-        nextRelay: NextRelay,
+        nextRelays: NextRelays,
         reason: ActorReconnectReason
     ) async throws {
-        guard let connectionState = try obfuscateConnection(nextRelay: nextRelay, settings: settings, reason: reason),
+        guard let connectionState = try obfuscateConnection(nextRelays: nextRelays, settings: settings, reason: reason),
               let targetState = state.targetStateForReconnect else { return }
 
         let activeKey = activeKey(from: connectionState, in: settings)
@@ -316,21 +316,21 @@ extension PacketTunnelActor {
         try await tunnelAdapter.start(configuration: configurationBuilder.makeConfiguration())
 
         // Resume tunnel monitoring and use IPv4 gateway as a probe address.
-        tunnelMonitor.start(probeAddress: connectionState.selectedRelay.endpoint.ipv4Gateway)
+        tunnelMonitor.start(probeAddress: connectionState.selectedRelays.exit.endpoint.ipv4Gateway) // TODO: Multihop
     }
 
     /**
-     Derive `ConnectionState` from current `state` updating it with new relay and settings.
+     Derive `ConnectionState` from current `state` updating it with new relays and settings.
 
      - Parameters:
-         - nextRelay: relay preference that should be used when selecting next relay.
+         - nextRelays: relay preference that should be used when selecting next relays.
          - settings: current settings
          - reason: reason for reconnect
 
      - Returns: New connection state or `nil` if current state is at or past `.disconnecting` phase.
      */
     internal func makeConnectionState(
-        nextRelay: NextRelay,
+        nextRelays: NextRelays,
         settings: Settings,
         reason: ActorReconnectReason
     ) throws -> State.ConnectionData? {
@@ -338,11 +338,11 @@ extension PacketTunnelActor {
         var networkReachability = defaultPathObserver.defaultPath?.networkReachability ?? .undetermined
         var lastKeyRotation: Date?
 
-        let callRelaySelector = { [self] maybeCurrentRelay, connectionCount in
-            try self.selectRelay(
-                nextRelay: nextRelay,
+        let callRelaySelector = { [self] maybeCurrentRelays, connectionCount in
+            try self.selectRelays(
+                nextRelays: nextRelays,
                 relayConstraints: settings.relayConstraints,
-                currentRelay: maybeCurrentRelay,
+                currentRelays: maybeCurrentRelays,
                 connectionAttemptCount: connectionCount
             )
         }
@@ -355,11 +355,11 @@ extension PacketTunnelActor {
             if reason == .connectionLoss {
                 connectionState.incrementAttemptCount()
             }
-            let selectedRelay = try callRelaySelector(
-                connectionState.selectedRelay,
+            let selectedRelays = try callRelaySelector(
+                connectionState.selectedRelays,
                 connectionState.connectionAttemptCount
             )
-            connectionState.selectedRelay = selectedRelay
+            connectionState.selectedRelays = selectedRelays
             connectionState.relayConstraints = settings.relayConstraints
             return connectionState
         case var .connecting(connectionState), var .reconnecting(connectionState):
@@ -368,11 +368,11 @@ extension PacketTunnelActor {
             }
             fallthrough
         case var .connected(connectionState):
-            let selectedRelay = try callRelaySelector(
-                connectionState.selectedRelay,
+            let selectedRelays = try callRelaySelector(
+                connectionState.selectedRelays,
                 connectionState.connectionAttemptCount
             )
-            connectionState.selectedRelay = selectedRelay
+            connectionState.selectedRelays = selectedRelays
             connectionState.relayConstraints = settings.relayConstraints
             connectionState.currentKey = settings.privateKey
             return connectionState
@@ -383,18 +383,18 @@ extension PacketTunnelActor {
         case .disconnecting, .disconnected:
             return nil
         }
-        let selectedRelay = try callRelaySelector(nil, 0)
+        let selectedRelays = try callRelaySelector(nil, 0)
         return State.ConnectionData(
-            selectedRelay: selectedRelay,
+            selectedRelays: selectedRelays,
             relayConstraints: settings.relayConstraints,
             currentKey: settings.privateKey,
             keyPolicy: keyPolicy,
             networkReachability: networkReachability,
             connectionAttemptCount: 0,
             lastKeyRotation: lastKeyRotation,
-            connectedEndpoint: selectedRelay.endpoint,
+            connectedEndpoint: selectedRelays.exit.endpoint, // TODO: Multihop
             transportLayer: .udp,
-            remotePort: selectedRelay.endpoint.ipv4Relay.port,
+            remotePort: selectedRelays.exit.endpoint.ipv4Relay.port, // TODO: Multihop
             isPostQuantum: settings.quantumResistance.isEnabled
         )
     }
@@ -409,22 +409,22 @@ extension PacketTunnelActor {
     }
 
     internal func obfuscateConnection(
-        nextRelay: NextRelay,
+        nextRelays: NextRelays,
         settings: Settings,
         reason: ActorReconnectReason
     ) throws -> State.ConnectionData? {
-        guard let connectionState = try makeConnectionState(nextRelay: nextRelay, settings: settings, reason: reason)
+        guard let connectionState = try makeConnectionState(nextRelays: nextRelays, settings: settings, reason: reason)
         else { return nil }
 
         let obfuscatedEndpoint = protocolObfuscator.obfuscate(
-            connectionState.selectedRelay.endpoint,
+            connectionState.selectedRelays.exit.endpoint, // TODO: Multihop
             settings: settings,
-            retryAttempts: connectionState.selectedRelay.retryAttempts
+            retryAttempts: connectionState.selectedRelays.exit.retryAttempts // TODO: Multihop
         )
 
         let transportLayer = protocolObfuscator.transportLayer.map { $0 } ?? .udp
         return State.ConnectionData(
-            selectedRelay: connectionState.selectedRelay,
+            selectedRelays: connectionState.selectedRelays,
             relayConstraints: connectionState.relayConstraints,
             currentKey: settings.privateKey,
             keyPolicy: connectionState.keyPolicy,
@@ -439,28 +439,28 @@ extension PacketTunnelActor {
     }
 
     /**
-     Select next relay to connect to based on `NextRelay` and other input parameters.
+     Select next relay to connect to based on `NextRelays` and other input parameters.
 
      - Parameters:
-         - nextRelay: next relay to connect to.
+         - nextRelays: next relays to connect to.
          - relayConstraints: relay constraints.
-         - currentRelay: currently selected relay.
+         - currentRelays: currently selected relays.
          - connectionAttemptCount: number of failed connection attempts so far.
 
-     - Returns: selector result that contains the credentials of the next relay that the tunnel should connect to.
+     - Returns: selector result that contains the credentials of the next relays that the tunnel should connect to.
      */
-    private func selectRelay(
-        nextRelay: NextRelay,
+    private func selectRelays(
+        nextRelays: NextRelays,
         relayConstraints: RelayConstraints,
-        currentRelay: SelectedRelay?,
+        currentRelays: SelectedRelays?,
         connectionAttemptCount: UInt
-    ) throws -> SelectedRelay {
-        switch nextRelay {
+    ) throws -> SelectedRelays {
+        switch nextRelays {
         case .current:
-            if let currentRelay {
-                return currentRelay
+            if let currentRelays {
+                return currentRelays
             } else {
-                // Fallthrough to .random when current relay is not set.
+                // Fallthrough to .random when current relays are not set.
                 fallthrough
             }
 
@@ -468,10 +468,10 @@ extension PacketTunnelActor {
             return try relaySelector.selectRelays(
                 with: relayConstraints,
                 connectionAttemptCount: connectionAttemptCount
-            ).exit // TODO: Multihop
+            )
 
-        case let .preSelected(selectedRelay):
-            return selectedRelay
+        case let .preSelected(selectedRelays):
+            return selectedRelays
         }
     }
 }
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActorCommand.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActorCommand.swift
index 21e2aca702f3..fd731a32e150 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActorCommand.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActorCommand.swift
@@ -19,7 +19,7 @@ extension PacketTunnelActor {
         case stop
 
         /// Reconnect tunnel.
-        case reconnect(NextRelay, reason: ActorReconnectReason = .userInitiated)
+        case reconnect(NextRelays, reason: ActorReconnectReason = .userInitiated)
 
         /// Enter blocked state.
         case error(BlockedStateReason)
@@ -46,14 +46,14 @@ extension PacketTunnelActor {
                 return "start"
             case .stop:
                 return "stop"
-            case let .reconnect(nextRelay, stopTunnelMonitor):
-                switch nextRelay {
+            case let .reconnect(nextRelays, stopTunnelMonitor):
+                switch nextRelays {
                 case .current:
                     return "reconnect(current, \(stopTunnelMonitor))"
                 case .random:
                     return "reconnect(random, \(stopTunnelMonitor))"
-                case let .preSelected(selectedRelay):
-                    return "reconnect(\(selectedRelay.hostname), \(stopTunnelMonitor))"
+                case let .preSelected(selectedRelays):
+                    return "reconnect(\(selectedRelays.exit.hostname), \(stopTunnelMonitor))" // TODO: Multihop
                 }
             case let .error(reason):
                 return "error(\(reason))"
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActorProtocol.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActorProtocol.swift
index df34f768cb80..02729d449c01 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActorProtocol.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActorProtocol.swift
@@ -11,6 +11,6 @@ import Foundation
 public protocol PacketTunnelActorProtocol {
     var observedState: ObservedState { get async }
 
-    func reconnect(to nextRelay: NextRelay, reconnectReason: ActorReconnectReason)
+    func reconnect(to nextRelays: NextRelays, reconnectReason: ActorReconnectReason)
     func notifyKeyRotation(date: Date?)
 }
diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift
index 200da62fffab..6d4056b68338 100644
--- a/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift
+++ b/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift
@@ -17,10 +17,10 @@ extension PacketTunnelActor {
         case startTunnelMonitor
         case stopTunnelMonitor
         case updateTunnelMonitorPath(NetworkPath)
-        case startConnection(NextRelay)
-        case restartConnection(NextRelay, ActorReconnectReason)
+        case startConnection(NextRelays)
+        case restartConnection(NextRelays, ActorReconnectReason)
         // trigger a reconnect, which becomes several effects depending on the state
-        case reconnect(NextRelay)
+        case reconnect(NextRelays)
         case stopTunnelAdapter
         case configureForErrorState(BlockedStateReason)
         case cacheActiveKey(Date?)
@@ -58,7 +58,7 @@ extension PacketTunnelActor {
                 return [
                     .startDefaultPathObserver,
                     .startTunnelMonitor,
-                    .startConnection(options.selectedRelay.map { .preSelected($0) } ?? .random),
+                    .startConnection(options.selectedRelays.map { .preSelected($0) } ?? .random),
                 ]
             case .stop:
                 return subreducerForStop(&state)
@@ -124,7 +124,7 @@ extension PacketTunnelActor {
         fileprivate static func subreducerForReconnect(
             _ state: State,
             _ reason: ActorReconnectReason,
-            _ nextRelay: NextRelay
+            _ nextRelays: NextRelays
         ) -> [PacketTunnelActor.Effect] {
             switch state {
             case .disconnected, .disconnecting, .initial:
@@ -133,9 +133,9 @@ extension PacketTunnelActor {
                 return []
             case .connecting, .connected, .reconnecting, .error, .negotiatingPostQuantumKey:
                 if reason == .userInitiated {
-                    return [.stopTunnelMonitor, .restartConnection(nextRelay, reason)]
+                    return [.stopTunnelMonitor, .restartConnection(nextRelays, reason)]
                 } else {
-                    return [.restartConnection(nextRelay, reason)]
+                    return [.restartConnection(nextRelays, reason)]
                 }
             }
         }
diff --git a/ios/PacketTunnelCore/Actor/StartOptions.swift b/ios/PacketTunnelCore/Actor/StartOptions.swift
index 0e484ef58fdd..4c8ad7587891 100644
--- a/ios/PacketTunnelCore/Actor/StartOptions.swift
+++ b/ios/PacketTunnelCore/Actor/StartOptions.swift
@@ -14,20 +14,20 @@ public struct StartOptions {
     /// The system that triggered the launch of packet tunnel.
     public var launchSource: LaunchSource
 
-    /// Pre-selected relay received from UI when available.
-    public var selectedRelay: SelectedRelay?
+    /// Pre-selected relays received from UI when available.
+    public var selectedRelays: SelectedRelays?
 
     /// Designated initializer.
-    public init(launchSource: LaunchSource, selectedRelay: SelectedRelay? = nil) {
+    public init(launchSource: LaunchSource, selectedRelays: SelectedRelays? = nil) {
         self.launchSource = launchSource
-        self.selectedRelay = selectedRelay
+        self.selectedRelays = selectedRelays
     }
 
     /// Returns a brief description suitable for output to tunnel provider log.
     public func logFormat() -> String {
         var s = "Start the tunnel via \(launchSource)"
-        if let selectedRelay {
-            s += ", connect to \(selectedRelay.hostname)"
+        if let selectedRelays {
+            s += ", connect to \(selectedRelays.exit.hostname)" // TODO: Multihop
         }
         s += "."
         return s
diff --git a/ios/PacketTunnelCore/Actor/State+Extensions.swift b/ios/PacketTunnelCore/Actor/State+Extensions.swift
index be1f05d52dc6..69d6579a9db9 100644
--- a/ios/PacketTunnelCore/Actor/State+Extensions.swift
+++ b/ios/PacketTunnelCore/Actor/State+Extensions.swift
@@ -47,7 +47,7 @@ extension State {
     func logFormat() -> String {
         switch self {
         case let .connecting(connState), let .connected(connState), let .reconnecting(connState):
-            let hostname = connState.selectedRelay.hostname
+            let hostname = connState.selectedRelays.exit.hostname // TODO: Multihop
 
             return """
             \(name) to \(hostname), \
diff --git a/ios/PacketTunnelCore/Actor/State.swift b/ios/PacketTunnelCore/Actor/State.swift
index 0aae1ac602d1..0ae4c79e3ea6 100644
--- a/ios/PacketTunnelCore/Actor/State.swift
+++ b/ios/PacketTunnelCore/Actor/State.swift
@@ -110,8 +110,8 @@ extension State {
 
     /// Data associated with states that hold connection data.
     struct ConnectionData: Equatable, StateAssociatedData {
-        /// Current selected relay.
-        public var selectedRelay: SelectedRelay
+        /// Current selected relays.
+        public var selectedRelays: SelectedRelays
 
         /// Last relay constraints read from settings.
         /// This is primarily used by packet tunnel for updating constraints in tunnel provider.
@@ -229,15 +229,15 @@ extension State.BlockingData {
 }
 
 /// Describes which relay the tunnel should connect to next.
-public enum NextRelay: Equatable, Codable {
-    /// Select next relay randomly.
+public enum NextRelays: Equatable, Codable {
+    /// Select next relays randomly.
     case random
 
-    /// Use currently selected relay, fallback to random if not set.
+    /// Use currently selected relays, fallback to random if not set.
     case current
 
-    /// Use pre-selected relay.
-    case preSelected(SelectedRelay)
+    /// Use pre-selected relays.
+    case preSelected(SelectedRelays)
 }
 
 /// Describes the reason for reconnection request.
diff --git a/ios/PacketTunnelCore/IPC/PacketTunnelOptions.swift b/ios/PacketTunnelCore/IPC/PacketTunnelOptions.swift
index ad632baa16f3..79bc030706f8 100644
--- a/ios/PacketTunnelCore/IPC/PacketTunnelOptions.swift
+++ b/ios/PacketTunnelCore/IPC/PacketTunnelOptions.swift
@@ -14,7 +14,7 @@ public struct PacketTunnelOptions {
     private enum Keys: String {
         /// Option key that holds serialized `SelectedRelay` value encoded using `JSONEncoder`.
         /// Used for passing the pre-selected relay in the GUI process to the Packet tunnel process.
-        case selectedRelay = "selected-relay"
+        case selectedRelays = "selected-relays"
 
         /// Option key that holds an `NSNumber` value, which is when set to `1` indicates that the tunnel was started by the system.
         /// System automatically provides that flag to the tunnel.
@@ -35,14 +35,14 @@ public struct PacketTunnelOptions {
         _rawOptions = rawOptions
     }
 
-    public func getSelectedRelay() throws -> SelectedRelay? {
-        guard let data = _rawOptions[Keys.selectedRelay.rawValue] as? Data else { return nil }
+    public func getSelectedRelays() throws -> SelectedRelays? {
+        guard let data = _rawOptions[Keys.selectedRelays.rawValue] as? Data else { return nil }
 
-        return try Self.decode(SelectedRelay.self, data)
+        return try Self.decode(SelectedRelays.self, data)
     }
 
-    public mutating func setSelectedRelay(_ value: SelectedRelay) throws {
-        _rawOptions[Keys.selectedRelay.rawValue] = try Self.encode(value) as NSData
+    public mutating func setSelectedRelays(_ value: SelectedRelays) throws {
+        _rawOptions[Keys.selectedRelays.rawValue] = try Self.encode(value) as NSData
     }
 
     public func isOnDemand() -> Bool {
diff --git a/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift b/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift
index 70626744379e..2b1126e8db54 100644
--- a/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift
+++ b/ios/PacketTunnelCore/IPC/TunnelProviderMessage.swift
@@ -11,7 +11,7 @@ import Foundation
 /// Enum describing supported app messages handled by packet tunnel provider.
 public enum TunnelProviderMessage: Codable, CustomStringConvertible {
     /// Request the tunnel to reconnect.
-    case reconnectTunnel(NextRelay)
+    case reconnectTunnel(NextRelays)
 
     /// Request the tunnel status.
     case getTunnelStatus
diff --git a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
index dc85adcaa71c..680b438f8395 100644
--- a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
+++ b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
@@ -95,15 +95,18 @@ final class AppMessageHandlerTests: XCTestCase {
             numberOfFailedAttempts: 0
         )
 
-        let selectedRelay = SelectedRelay(
-            endpoint: match.endpoint,
-            hostname: match.relay.hostname,
-            location: match.location,
-            retryAttempts: 0
+        let selectedRelays = SelectedRelays(
+            entry: nil,
+            exit: SelectedRelay(
+                endpoint: match.endpoint,
+                hostname: match.relay.hostname,
+                location: match.location,
+                retryAttempts: 0
+            )
         )
 
         _ = try? await appMessageHandler.handleAppMessage(
-            TunnelProviderMessage.reconnectTunnel(.preSelected(selectedRelay)).encode()
+            TunnelProviderMessage.reconnectTunnel(.preSelected(selectedRelays)).encode()
         )
 
         await fulfillment(of: [reconnectExpectation], timeout: .UnitTest.timeout)
diff --git a/ios/PacketTunnelCoreTests/EventChannelTests.swift b/ios/PacketTunnelCoreTests/EventChannelTests.swift
index 3cdd0f7b0d15..59d798a8a262 100644
--- a/ios/PacketTunnelCoreTests/EventChannelTests.swift
+++ b/ios/PacketTunnelCoreTests/EventChannelTests.swift
@@ -90,7 +90,7 @@ extension AsyncSequence {
 
 /// Simplified version of `Event` that can be used in tests and easily compared against.
 enum SimplifiedEvent: Equatable {
-    case start, stop, reconnect(NextRelay), switchKey, other
+    case start, stop, reconnect(NextRelays), switchKey, other
 }
 
 extension PacketTunnelActor.Event {
diff --git a/ios/PacketTunnelCoreTests/Mocks/PacketTunnelActorStub.swift b/ios/PacketTunnelCoreTests/Mocks/PacketTunnelActorStub.swift
index 1c3c1533ae6e..d526f1f168b9 100644
--- a/ios/PacketTunnelCoreTests/Mocks/PacketTunnelActorStub.swift
+++ b/ios/PacketTunnelCoreTests/Mocks/PacketTunnelActorStub.swift
@@ -23,7 +23,7 @@ struct PacketTunnelActorStub: PacketTunnelActorProtocol {
         }
     }
 
-    func reconnect(to nextRelay: PacketTunnelCore.NextRelay, reconnectReason: ActorReconnectReason) {
+    func reconnect(to nextRelays: NextRelays, reconnectReason: ActorReconnectReason) {
         reconnectExpectation?.fulfill()
     }