diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 7d4cd6e81c02..075309bfee37 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -616,6 +616,8 @@ A935594C2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A935594B2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift */; }; A94D691A2ABAD66700413DD4 /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58FE25E22AA72AE9003D1918 /* WireGuardKitTypes */; }; A94D691B2ABAD66700413DD4 /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58FE25E72AA7399D003D1918 /* WireGuardKitTypes */; }; + A95EEE362B722CD600A8A39B /* TunnelMonitorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95EEE352B722CD600A8A39B /* TunnelMonitorState.swift */; }; + A95EEE382B722DFC00A8A39B /* PingStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95EEE372B722DFC00A8A39B /* PingStats.swift */; }; A970C89D2B29E38C000A7684 /* Socks5UsernamePasswordCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A970C89C2B29E38C000A7684 /* Socks5UsernamePasswordCommand.swift */; }; A97D25AE2B0BB18100946B2D /* ProtocolObfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97D25AD2B0BB18100946B2D /* ProtocolObfuscator.swift */; }; A97D25B02B0BB5C400946B2D /* ProtocolObfuscationStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97D25AF2B0BB5C400946B2D /* ProtocolObfuscationStub.swift */; }; @@ -1779,6 +1781,8 @@ A935594B2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAvailabilityTestRequest.swift; sourceTree = ""; }; A935594D2B4E919F00D5D524 /* Api.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Api.xcconfig; sourceTree = ""; }; A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = ""; }; + A95EEE352B722CD600A8A39B /* TunnelMonitorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorState.swift; sourceTree = ""; }; + A95EEE372B722DFC00A8A39B /* PingStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingStats.swift; sourceTree = ""; }; A970C89C2B29E38C000A7684 /* Socks5UsernamePasswordCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Socks5UsernamePasswordCommand.swift; sourceTree = ""; }; A97D25AD2B0BB18100946B2D /* ProtocolObfuscator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolObfuscator.swift; sourceTree = ""; }; A97D25AF2B0BB5C400946B2D /* ProtocolObfuscationStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolObfuscationStub.swift; sourceTree = ""; }; @@ -3116,9 +3120,11 @@ isa = PBXGroup; children = ( 58225D252A84E8A10083D7F1 /* DefaultPathObserverProtocol.swift */, + A95EEE372B722DFC00A8A39B /* PingStats.swift */, 582403162A821FD700163DE8 /* TunnelDeviceInfoProtocol.swift */, 58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */, 58C7A42C2A85067A0060C66F /* TunnelMonitorProtocol.swift */, + A95EEE352B722CD600A8A39B /* TunnelMonitorState.swift */, 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */, 58A3BDAF28A1821A00C8C2C6 /* WgStats.swift */, ); @@ -4658,6 +4664,7 @@ 58C7A4592A863FB90060C66F /* WgStats.swift in Sources */, 7AD0AA1F2AD6C8B900119E10 /* URLRequestProxyProtocol.swift in Sources */, 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */, + A95EEE362B722CD600A8A39B /* TunnelMonitorState.swift in Sources */, 58FE25DB2AA72A8F003D1918 /* StartOptions.swift in Sources */, A97D25AE2B0BB18100946B2D /* ProtocolObfuscator.swift in Sources */, 583832212AC3174700EA2071 /* PacketTunnelActor+NetworkReachability.swift in Sources */, @@ -4670,6 +4677,7 @@ 5838322B2AC3EF9600EA2071 /* CommandChannel.swift in Sources */, 586C145A2AC4735F00245C01 /* PacketTunnelActor+Public.swift in Sources */, 58342C042AAB61FB003BA12D /* State+Extensions.swift in Sources */, + A95EEE382B722DFC00A8A39B /* PingStats.swift in Sources */, 583832272AC3193600EA2071 /* PacketTunnelActor+SleepCycle.swift in Sources */, 58FE25DC2AA72A8F003D1918 /* AnyTask.swift in Sources */, 58FE25D92AA72A8F003D1918 /* AutoCancellingTask.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift index d88cc1e4cad4..e1fdc52f16ed 100644 --- a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift +++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift @@ -138,8 +138,7 @@ class RedeemVoucherViewController: UIViewController, UINavigationControllerDeleg contentView.state = .logout - Task { - [weak self] in + Task { [weak self] in guard let self else { return } await interactor.logout() contentView.state = .initial diff --git a/ios/PacketTunnelCore/TunnelMonitor/PingStats.swift b/ios/PacketTunnelCore/TunnelMonitor/PingStats.swift new file mode 100644 index 000000000000..92054665f9c9 --- /dev/null +++ b/ios/PacketTunnelCore/TunnelMonitor/PingStats.swift @@ -0,0 +1,21 @@ +// +// PingStats.swift +// PacketTunnelCore +// +// Created by Marco Nikic on 2024-02-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Ping statistics. +struct PingStats { + /// Dictionary holding sequence and corresponding date when echo request took place. + var requests = [UInt16: Date]() + + /// Timestamp when last echo request was sent. + var lastRequestDate: Date? + + /// Timestamp when last echo reply was received. + var lastReplyDate: Date? +} diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift index 55facd9048c7..40adf180ce08 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift @@ -14,188 +14,6 @@ import NetworkExtension /// Tunnel monitor. public final class TunnelMonitor: TunnelMonitorProtocol { - /// Connection state. - private enum ConnectionState { - /// Initialized and doing nothing. - case stopped - - /// Preparing to start. - /// Intermediate state before receiving the first path update. - case pendingStart - - /// Establishing connection. - case connecting - - /// Connection is established. - case connected - - /// Delegate is recovering connection. - /// Delegate has to call `start(probeAddress:)` to complete recovery and resume monitoring. - case recovering - - /// Waiting for network connectivity. - case waitingConnectivity - } - - /// Tunnel monitor state. - private struct State { - /// Current connection state. - var connectionState: ConnectionState = .stopped - - /// Network counters. - var netStats = WgStats() - - /// Ping stats. - var pingStats = PingStats() - - /// Reference date used to determine if timeout has occurred. - var timeoutReference = Date() - - /// Last seen change in rx counter. - var lastSeenRx: Date? - - /// Last seen change in tx counter. - var lastSeenTx: Date? - - /// Whether periodic heartbeat is suspended. - var isHeartbeatSuspended = false - - /// Retry attempt. - var retryAttempt: UInt32 = 0 - - // Timings and timeouts. - let timings: TunnelMonitorTimings - - func evaluateConnection(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { - switch connectionState { - case .connecting: - return handleConnectingState(now: now, pingTimeout: pingTimeout) - case .connected: - return handleConnectedState(now: now, pingTimeout: pingTimeout) - default: - return .ok - } - } - - func getPingTimeout() -> Duration { - switch connectionState { - case .connecting: - let multiplier = timings.establishTimeoutMultiplier.saturatingPow(retryAttempt) - let nextTimeout = timings.initialEstablishTimeout * Double(multiplier) - - if nextTimeout.isFinite, nextTimeout < timings.maxEstablishTimeout { - return nextTimeout - } else { - return timings.maxEstablishTimeout - } - - case .pendingStart, .connected, .waitingConnectivity, .stopped, .recovering: - return timings.pingTimeout - } - } - - mutating func updateNetStats(newStats: WgStats, now: Date) { - if newStats.bytesReceived > netStats.bytesReceived { - lastSeenRx = now - } - - if newStats.bytesSent > netStats.bytesSent { - lastSeenTx = now - } - - netStats = newStats - } - - mutating func updatePingStats(sendResult: PingerSendResult, now: Date) { - pingStats.requests.updateValue(now, forKey: sendResult.sequenceNumber) - pingStats.lastRequestDate = now - } - - mutating func setPingReplyReceived(_ sequenceNumber: UInt16, now: Date) -> Date? { - guard let pingTimestamp = pingStats.requests.removeValue(forKey: sequenceNumber) else { - return nil - } - - pingStats.lastReplyDate = now - timeoutReference = now - - return pingTimestamp - } - - private func handleConnectingState(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { - if now.timeIntervalSince(timeoutReference) >= pingTimeout { - return .pingTimeout - } - - guard let lastRequestDate = pingStats.lastRequestDate else { - return .sendInitialPing - } - - if now.timeIntervalSince(lastRequestDate) >= timings.pingDelay { - return .sendNextPing - } - - return .ok - } - - private func handleConnectedState(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { - if now.timeIntervalSince(timeoutReference) >= pingTimeout, !isHeartbeatSuspended { - return .pingTimeout - } - - guard let lastRequestDate = pingStats.lastRequestDate else { - return .sendInitialPing - } - - let timeSinceLastPing = now.timeIntervalSince(lastRequestDate) - if let lastReplyDate = pingStats.lastReplyDate, - lastRequestDate.timeIntervalSince(lastReplyDate) >= timings.heartbeatReplyTimeout, - timeSinceLastPing >= timings.pingDelay, !isHeartbeatSuspended { - return .retryHeartbeatPing - } - - guard let lastSeenRx, let lastSeenTx else { return .ok } - - let rxTimeElapsed = now.timeIntervalSince(lastSeenRx) - let txTimeElapsed = now.timeIntervalSince(lastSeenTx) - - if timeSinceLastPing >= timings.heartbeatPingInterval { - // Send heartbeat if traffic is flowing. - if rxTimeElapsed <= timings.trafficFlowTimeout || txTimeElapsed <= timings.trafficFlowTimeout { - return .sendHeartbeatPing - } - - if !isHeartbeatSuspended { - return .suspendHeartbeat - } - } - - if timeSinceLastPing >= timings.pingDelay { - if txTimeElapsed >= timings.trafficTimeout || rxTimeElapsed >= timings.trafficTimeout { - return .trafficTimeout - } - - if lastSeenTx > lastSeenRx, rxTimeElapsed >= timings.inboundTrafficTimeout { - return .inboundTrafficTimeout - } - } - - return .ok - } - } - - /// Ping statistics. - private struct PingStats { - /// Dictionary holding sequence and corresponding date when echo request took place. - var requests = [UInt16: Date]() - - /// Timestamp when last echo request was sent. - var lastRequestDate: Date? - - /// Timestamp when last echo reply was received. - var lastReplyDate: Date? - } - private let tunnelDeviceInfo: TunnelDeviceInfoProtocol private let nslock = NSLock() @@ -207,7 +25,7 @@ public final class TunnelMonitor: TunnelMonitorProtocol { private var isObservingDefaultPath = false private var timer: DispatchSourceTimer? - private var state: State + private var state: TunnelMonitorState private var probeAddress: IPv4Address? private let logger = Logger(label: "TunnelMonitor") @@ -236,7 +54,7 @@ public final class TunnelMonitor: TunnelMonitorProtocol { self.tunnelDeviceInfo = tunnelDeviceInfo self.timings = timings - state = State(timings: timings) + state = TunnelMonitorState(timings: timings) self.pinger = pinger self.pinger.onReply = { [weak self] reply in @@ -547,18 +365,6 @@ public final class TunnelMonitor: TunnelMonitorProtocol { } } - private enum ConnectionEvaluation { - case ok - case sendInitialPing - case sendNextPing - case sendHeartbeatPing - case retryHeartbeatPing - case suspendHeartbeat - case inboundTrafficTimeout - case trafficTimeout - case pingTimeout - } - private func getStats() -> WgStats? { do { return try tunnelDeviceInfo.getStats() @@ -568,6 +374,4 @@ public final class TunnelMonitor: TunnelMonitorProtocol { return nil } } - - // swiftlint:disable:next file_length } diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift new file mode 100644 index 000000000000..72b2bae08330 --- /dev/null +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift @@ -0,0 +1,192 @@ +// +// TunnelMonitorState.swift +// PacketTunnelCore +// +// Created by Marco Nikic on 2024-02-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// Connection state. +enum TunnelMonitorConnectionState { + /// Initialized and doing nothing. + case stopped + + /// Preparing to start. + /// Intermediate state before receiving the first path update. + case pendingStart + + /// Establishing connection. + case connecting + + /// Connection is established. + case connected + + /// Delegate is recovering connection. + /// Delegate has to call `start(probeAddress:)` to complete recovery and resume monitoring. + case recovering + + /// Waiting for network connectivity. + case waitingConnectivity +} + +enum ConnectionEvaluation { + case ok + case sendInitialPing + case sendNextPing + case sendHeartbeatPing + case retryHeartbeatPing + case suspendHeartbeat + case inboundTrafficTimeout + case trafficTimeout + case pingTimeout +} + +/// Tunnel monitor state. +struct TunnelMonitorState { + /// Current connection state. + var connectionState: TunnelMonitorConnectionState = .stopped + + /// Network counters. + var netStats = WgStats() + + /// Ping stats. + var pingStats = PingStats() + + /// Reference date used to determine if timeout has occurred. + var timeoutReference = Date() + + /// Last seen change in rx counter. + var lastSeenRx: Date? + + /// Last seen change in tx counter. + var lastSeenTx: Date? + + /// Whether periodic heartbeat is suspended. + var isHeartbeatSuspended = false + + /// Retry attempt. + var retryAttempt: UInt32 = 0 + + // Timings and timeouts. + let timings: TunnelMonitorTimings + + func evaluateConnection(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { + switch connectionState { + case .connecting: + return handleConnectingState(now: now, pingTimeout: pingTimeout) + case .connected: + return handleConnectedState(now: now, pingTimeout: pingTimeout) + default: + return .ok + } + } + + func getPingTimeout() -> Duration { + switch connectionState { + case .connecting: + let multiplier = timings.establishTimeoutMultiplier.saturatingPow(retryAttempt) + let nextTimeout = timings.initialEstablishTimeout * Double(multiplier) + + if nextTimeout.isFinite, nextTimeout < timings.maxEstablishTimeout { + return nextTimeout + } else { + return timings.maxEstablishTimeout + } + + case .pendingStart, .connected, .waitingConnectivity, .stopped, .recovering: + return timings.pingTimeout + } + } + + mutating func updateNetStats(newStats: WgStats, now: Date) { + if newStats.bytesReceived > netStats.bytesReceived { + lastSeenRx = now + } + + if newStats.bytesSent > netStats.bytesSent { + lastSeenTx = now + } + + netStats = newStats + } + + mutating func updatePingStats(sendResult: PingerSendResult, now: Date) { + pingStats.requests.updateValue(now, forKey: sendResult.sequenceNumber) + pingStats.lastRequestDate = now + } + + mutating func setPingReplyReceived(_ sequenceNumber: UInt16, now: Date) -> Date? { + guard let pingTimestamp = pingStats.requests.removeValue(forKey: sequenceNumber) else { + return nil + } + + pingStats.lastReplyDate = now + timeoutReference = now + + return pingTimestamp + } + + private func handleConnectingState(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { + if now.timeIntervalSince(timeoutReference) >= pingTimeout { + return .pingTimeout + } + + guard let lastRequestDate = pingStats.lastRequestDate else { + return .sendInitialPing + } + + if now.timeIntervalSince(lastRequestDate) >= timings.pingDelay { + return .sendNextPing + } + + return .ok + } + + private func handleConnectedState(now: Date, pingTimeout: Duration) -> ConnectionEvaluation { + if now.timeIntervalSince(timeoutReference) >= pingTimeout, !isHeartbeatSuspended { + return .pingTimeout + } + + guard let lastRequestDate = pingStats.lastRequestDate else { + return .sendInitialPing + } + + let timeSinceLastPing = now.timeIntervalSince(lastRequestDate) + if let lastReplyDate = pingStats.lastReplyDate, + lastRequestDate.timeIntervalSince(lastReplyDate) >= timings.heartbeatReplyTimeout, + timeSinceLastPing >= timings.pingDelay, !isHeartbeatSuspended { + return .retryHeartbeatPing + } + + guard let lastSeenRx, let lastSeenTx else { return .ok } + + let rxTimeElapsed = now.timeIntervalSince(lastSeenRx) + let txTimeElapsed = now.timeIntervalSince(lastSeenTx) + + if timeSinceLastPing >= timings.heartbeatPingInterval { + // Send heartbeat if traffic is flowing. + if rxTimeElapsed <= timings.trafficFlowTimeout || txTimeElapsed <= timings.trafficFlowTimeout { + return .sendHeartbeatPing + } + + if !isHeartbeatSuspended { + return .suspendHeartbeat + } + } + + if timeSinceLastPing >= timings.pingDelay { + if txTimeElapsed >= timings.trafficTimeout || rxTimeElapsed >= timings.trafficTimeout { + return .trafficTimeout + } + + if lastSeenTx > lastSeenRx, rxTimeElapsed >= timings.inboundTrafficTimeout { + return .inboundTrafficTimeout + } + } + + return .ok + } +}