diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-01-31 15:14:41 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-02-11 09:31:31 +0100 |
| commit | 0421eadffbe57f73b920225b397adf6cd0789f88 (patch) | |
| tree | 1a08f49c915587237aea41418148feed8d5b6fe9 | |
| parent | a5690d0381aea34bf5fde83fd206aee75a119f72 (diff) | |
| download | mullvadvpn-0421eadffbe57f73b920225b397adf6cd0789f88.tar.xz mullvadvpn-0421eadffbe57f73b920225b397adf6cd0789f88.zip | |
Add TunnelMonitorActor
9 files changed, 265 insertions, 8 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 7a4a5271e0..d7c2c202a1 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -753,6 +753,7 @@ A939661B2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A939661A2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift */; }; A93969812CE606190032A7A0 /* Maybenot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9840BB32C69F78A0030F05E /* Maybenot.swift */; }; A93A0BEC2D4CDE97001C9246 /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93A0BEB2D4CDE97001C9246 /* TunnelSettingsV7.swift */; }; + A93A0BEE2D4CEA7B001C9246 /* TunnelMonitorActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93A0BED2D4CEA7B001C9246 /* TunnelMonitorActor.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 */; }; @@ -2162,6 +2163,7 @@ A935594D2B4E919F00D5D524 /* Api.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Api.xcconfig; sourceTree = "<group>"; }; A939661A2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerMultiProcessUpgradeTests.swift; sourceTree = "<group>"; }; A93A0BEB2D4CDE97001C9246 /* TunnelSettingsV7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV7.swift; sourceTree = "<group>"; }; + A93A0BED2D4CEA7B001C9246 /* TunnelMonitorActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorActor.swift; sourceTree = "<group>"; }; A944F2712B8E02E800473F4C /* libmullvad_ios.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libmullvad_ios.a; path = "../target/aarch64-apple-ios/debug/libmullvad_ios.a"; sourceTree = "<group>"; }; A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = "<group>"; }; A948809A2BC9308D0090A44C /* EphemeralPeerExchangeActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralPeerExchangeActor.swift; sourceTree = "<group>"; }; @@ -3883,6 +3885,7 @@ A95EEE352B722CD600A8A39B /* TunnelMonitorState.swift */, 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */, 58A3BDAF28A1821A00C8C2C6 /* WgStats.swift */, + A93A0BED2D4CEA7B001C9246 /* TunnelMonitorActor.swift */, ); path = TunnelMonitor; sourceTree = "<group>"; @@ -5861,6 +5864,7 @@ 44B3C43A2BFE2C800079782C /* PacketTunnelActorReducer.swift in Sources */, 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */, A95EEE362B722CD600A8A39B /* TunnelMonitorState.swift in Sources */, + A93A0BEE2D4CEA7B001C9246 /* TunnelMonitorActor.swift in Sources */, 58FE25DB2AA72A8F003D1918 /* StartOptions.swift in Sources */, A97D25AE2B0BB18100946B2D /* ProtocolObfuscator.swift in Sources */, 583832212AC3174700EA2071 /* PacketTunnelActor+NetworkReachability.swift in Sources */, diff --git a/ios/PacketTunnelCore/Pinger/PingerProtocol.swift b/ios/PacketTunnelCore/Pinger/PingerProtocol.swift index 1c06c634f9..af9bc31e69 100644 --- a/ios/PacketTunnelCore/Pinger/PingerProtocol.swift +++ b/ios/PacketTunnelCore/Pinger/PingerProtocol.swift @@ -29,8 +29,8 @@ public struct PingerSendResult { } /// A type capable of sending and receving ICMP traffic. -public protocol PingerProtocol { - var onReply: ((PingerReply) -> Void)? { get set } +public protocol PingerProtocol: AnyObject, Sendable { + var onReply: (@Sendable (PingerReply) -> Void)? { get set } func startPinging(destAddress: IPv4Address) throws func stopPinging() diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelDeviceInfoProtocol.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelDeviceInfoProtocol.swift index 664fd69ed9..d9a0a33250 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelDeviceInfoProtocol.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelDeviceInfoProtocol.swift @@ -9,7 +9,7 @@ import Foundation /// A type that can provide statistics and basic information about tunnel device. -public protocol TunnelDeviceInfoProtocol { +public protocol TunnelDeviceInfoProtocol: Sendable { /// Returns tunnel interface name (i.e utun0) if available. var interfaceName: String? { get } diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift index 778b8ac417..c27c5b0a20 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitor.swift @@ -21,9 +21,8 @@ public final class TunnelMonitor: TunnelMonitorProtocol { private let eventQueue: DispatchQueue private let timings: TunnelMonitorTimings - private var pinger: PingerProtocol - private var isObservingDefaultPath = false - private var timer: DispatchSourceTimer? + private let pinger: PingerProtocol + nonisolated(unsafe) private var timer: DispatchSourceTimer? private var state: TunnelMonitorState private var probeAddress: IPv4Address? diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorActor.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorActor.swift new file mode 100644 index 0000000000..051f4d9c49 --- /dev/null +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorActor.swift @@ -0,0 +1,218 @@ +// +// TunnelMonitorActor.swift +// PacketTunnelCore +// +// Created by Marco Nikic on 2025-01-31. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging +import MullvadTypes +import Network +import NetworkExtension + +public actor TunnelMonitorActor: TunnelMonitorActorProtocol { + private let pinger: any PingerProtocol + private let timings: TunnelMonitorTimings + private var state: TunnelMonitorState + private let tunnelDeviceInfo: any TunnelDeviceInfoProtocol + private var probeAddress: IPv4Address? + private var timer: DispatchSourceTimer? + private var eventHandler: AsyncStream<TunnelMonitorEvent>.Continuation? + + public let eventStream: AsyncStream<TunnelMonitorEvent> + + init( + pinger: any PingerProtocol, + timings: TunnelMonitorTimings, + tunnelDeviceInfo: any TunnelDeviceInfoProtocol + ) { + self.pinger = pinger + self.timings = timings + self.state = TunnelMonitorState(timings: timings) + self.tunnelDeviceInfo = tunnelDeviceInfo + + var innerContinuation: AsyncStream<TunnelMonitorEvent>.Continuation? + let stream = AsyncStream<TunnelMonitorEvent> { continuation in + innerContinuation = continuation + } + + self.eventStream = stream + self.eventHandler = innerContinuation + } + + // MARK: - Public API + + public func start(probeAddress: IPv4Address) async throws { + print(#function) + guard state.connectionState == .stopped else { return } + + self.probeAddress = probeAddress + self.state.connectionState = .pendingStart + pinger.onReply = { [weak self] reply in + Task { + await self?.handlePingerReply(reply) + } + } + + try startMonitoring() + } + + public func stop() async { + print(#function) + _stop() + } + + public func wake() async { + print(#function) + } + + public func sleep() async { + print(#function) + } + + public func handleNetworkPathUpdate(_ networkPath: any NetworkPath) async { + print(#function) + + let pathStatus = networkPath.status + let isReachable = pathStatus == .satisfiable || pathStatus == .satisfied + + switch state.connectionState { + case .pendingStart: + if isReachable { + try? startMonitoring() + } else { + state.connectionState = .waitingConnectivity + } + + case .waitingConnectivity: + guard isReachable else { return } + + try? startMonitoring() + + case .connecting, .connected: + guard !isReachable else { return } + + state.connectionState = .waitingConnectivity + stopMonitoring(resetRetryAttempt: true) + + case .stopped, .recovering: + break + } + } + + // MARK: - Private API + + private func _stop(restarting: Bool = false) { + guard state.connectionState != .stopped else { return } + + probeAddress = nil + stopMonitoring(resetRetryAttempt: restarting == false) + state.connectionState = .stopped + } + + // TODO: Should this be marked `throws` ? + private func startMonitoring() throws { + guard let probeAddress else { return } + + try pinger.startPinging(destAddress: probeAddress) + state.connectionState = .connecting + startConnectivityCheckTimer() + } + + private func stopMonitoring(resetRetryAttempt: Bool) { + stopConnectivityCheckTimer() + pinger.stopPinging() + state.reset(resetRetryAttempts: resetRetryAttempt) + } + + private func handlePingerReply(_ reply: PingerReply) {} + + private func checkConnectivity() { + guard let newStats = try? tunnelDeviceInfo.getStats(), + state.connectionState == .connecting || state.connectionState == .connected + else { return } + + // Check if counters were reset. + let isStatsReset = newStats.bytesReceived < state.netStats.bytesReceived || + newStats.bytesSent < state.netStats.bytesSent + + guard !isStatsReset else { + state.netStats = newStats + return + } + + let now = Date() + state.updateNetStats(newStats: newStats, now: now) + + let timeout = state.getPingTimeout() + let evaluation = state.evaluateConnection(now: now, pingTimeout: timeout) + + switch evaluation { + case .ok: + break + + case .pingTimeout: + startConnectionRecovery() + + case .suspendHeartbeat: + state.isHeartbeatSuspended = true + + case .sendHeartbeatPing, .retryHeartbeatPing, .sendNextPing, .sendInitialPing, + .inboundTrafficTimeout, .trafficTimeout: + if state.isHeartbeatSuspended { + state.isHeartbeatSuspended = false + state.timeoutReference = now + } + sendPing(now: now) + } + } + + private func sendPing(now: Date) { + do { + let sendResult = try pinger.send() + state.updatePingStats(sendResult: sendResult, now: now) + } catch { + print("Failed to send ping.") + } + } + + private func sendConnectionEstablishedEvent() { + eventHandler?.yield(.connectionEstablished) + } + + private func sendConnectionLostEvent() { + eventHandler?.yield(.connectionLost) + } + + private func startConnectionRecovery() { + stopMonitoring(resetRetryAttempt: false) + + state.retryAttempt = state.retryAttempt.saturatingAddition(1) + state.connectionState = .recovering + probeAddress = nil + + sendConnectionLostEvent() + } + + private func startConnectivityCheckTimer() { + print(#function) + + guard timer?.isCancelled == false else { return } + + let timerSource = DispatchSource.makeTimerSource() + timerSource.setEventHandler(handler: checkConnectivity) + timerSource.schedule(wallDeadline: .now(), repeating: timings.connectivityCheckInterval.timeInterval) + timerSource.activate() + + timer?.cancel() + timer = timerSource + + state.timeoutReference = Date() + } + + private func stopConnectivityCheckTimer() { + print(#function) + } +} diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorProtocol.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorProtocol.swift index 5bc9d8e5f5..77bdeb7671 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorProtocol.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorProtocol.swift @@ -42,3 +42,27 @@ public protocol TunnelMonitorProtocol: AnyObject, Sendable { /// Handle changes in network path, eg. update connection state and monitoring. func handleNetworkPathUpdate(_ networkPath: NetworkPath) } + +/// A type that can provide tunnel monitoring. +public protocol TunnelMonitorActorProtocol: Sendable { + /// Event handler that starts receiving events after the call to `start(probeAddress:)`. + var eventStream: AsyncStream<TunnelMonitorEvent> { get } + + /// Start monitoring connection by pinging the given IP address. + /// Normally we should only give an address of a tunnel gateway here which is reachable over tunnel interface. + func start(probeAddress: IPv4Address) async throws + + /// Stop monitoring connection. + func stop() async + + /// Restarts internal timers and gracefully handles transition from sleep to awake device state. + /// Call this method when packet tunnel provider receives a wake event. + func wake() async + + /// Cancels internal timers and time dependent data in preparation for device sleep. + /// Call this method when packet tunnel provider receives a sleep event. + func sleep() async + + /// Handle changes in network path, eg. update connection state and monitoring. + func handleNetworkPathUpdate(_ networkPath: NetworkPath) async +} diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift index 6efdb8e476..47b57467d1 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorState.swift @@ -189,4 +189,16 @@ struct TunnelMonitorState { return .ok } + + mutating func reset(resetRetryAttempts: Bool) { + netStats = WgStats() + lastSeenRx = nil + lastSeenTx = nil + pingStats = PingStats() + isHeartbeatSuspended = false + + if resetRetryAttempts { + retryAttempt = 0 + } + } } diff --git a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorTimings.swift b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorTimings.swift index 7e925ec932..ee1836bb22 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorTimings.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/TunnelMonitorTimings.swift @@ -8,7 +8,7 @@ import MullvadTypes -public struct TunnelMonitorTimings { +public struct TunnelMonitorTimings: Sendable { /// Interval for periodic heartbeat ping issued when traffic is flowing. /// Should help to detect connectivity issues on networks that drop traffic in one of directions, /// regardless if tx/rx counters are being updated. diff --git a/ios/PacketTunnelCore/TunnelMonitor/WgStats.swift b/ios/PacketTunnelCore/TunnelMonitor/WgStats.swift index a4fb0fe2a3..b227efa25a 100644 --- a/ios/PacketTunnelCore/TunnelMonitor/WgStats.swift +++ b/ios/PacketTunnelCore/TunnelMonitor/WgStats.swift @@ -8,7 +8,7 @@ import Foundation -public struct WgStats { +public struct WgStats: Sendable { public let bytesReceived: UInt64 public let bytesSent: UInt64 |
