From 832d58e1024dccdfb8866396db67d63dfd9b5147 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 18 Jun 2024 11:14:18 +0200 Subject: [PATCH] WIP --- Package.swift | 1 + .../ProcessInfo+SystemBootDate.swift | 32 ++ .../PacketTunnelProvider.swift | 20 +- .../Pixels/VPNTunnelStartPixelHandler.swift | 100 +++++++ .../NetworkProtection/StartupOptions.swift | 22 +- .../VPNTunnelStartPixelHandlerTests.swift | 277 ++++++++++++++++++ 6 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 Sources/Common/Extensions/ProcessInfo+SystemBootDate.swift create mode 100644 Sources/NetworkProtection/Pixels/VPNTunnelStartPixelHandler.swift create mode 100644 Tests/NetworkProtectionTests/Pixels/VPNTunnelStartPixelHandlerTests.swift diff --git a/Package.swift b/Package.swift index 10caf3959..62c5799b8 100644 --- a/Package.swift +++ b/Package.swift @@ -292,6 +292,7 @@ let package = Package( .target( name: "NetworkProtection", dependencies: [ + "PixelKit", .target(name: "WireGuardC"), .product(name: "WireGuard", package: "wireguard-apple"), "Common", diff --git a/Sources/Common/Extensions/ProcessInfo+SystemBootDate.swift b/Sources/Common/Extensions/ProcessInfo+SystemBootDate.swift new file mode 100644 index 000000000..dfd491a0a --- /dev/null +++ b/Sources/Common/Extensions/ProcessInfo+SystemBootDate.swift @@ -0,0 +1,32 @@ +// +// ProcessInfo.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension ProcessInfo { + + public static func systemBootDate() -> Date { + var tv = timeval() + var tvSize = MemoryLayout.size + let err = sysctlbyname("kern.boottime", &tv, &tvSize, nil, 0) + guard err == 0, tvSize == MemoryLayout.size else { + return Date(timeIntervalSince1970: 0) + } + return Date(timeIntervalSince1970: Double(tv.tv_sec) + Double(tv.tv_usec) / 1_000_000.0) + } +} diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index f0f51fe2c..9ca85c499 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -566,20 +566,27 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { open override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { Task { @MainActor in - providerEvents.fire(.tunnelStartAttempt(.begin)) + let options = options ?? [:] + let startupMethod = StartupOptions.StartupMethod(options: options) + let startedByOnDemand = startupMethod == .automaticOnDemand + let pixelHandler = VPNTunnelStartPixelHandler(eventHandler: providerEvents, userDefaults: defaults) + + //providerEvents.fire(.tunnelStartAttempt(.begin)) + pixelHandler.handle(.begin, onDemand: startedByOnDemand) prepareToConnect(using: tunnelProviderProtocol) connectionStatus = .connecting - let startupOptions = StartupOptions(options: options ?? [:]) + let startupOptions = StartupOptions(options: options) os_log("Starting tunnel with options: %{public}s", log: .networkProtection, startupOptions.description) resetIssueStateOnTunnelStart(startupOptions) - let internalCompletionHandler = { [weak self, providerEvents] (error: Error?) in + let internalCompletionHandler = { [weak self] (error: Error?) in guard let error else { completionHandler(nil) - providerEvents.fire(.tunnelStartAttempt(.success)) + //providerEvents.fire(.tunnelStartAttempt(.success)) + pixelHandler.handle(.success, onDemand: startedByOnDemand) return } @@ -591,11 +598,12 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self?.connectionStatus = .disconnected self?.knownFailureStore.lastKnownFailure = KnownFailure(error) - providerEvents.fire(.tunnelStartAttempt(.failure(error))) + //providerEvents.fire(.tunnelStartAttempt(.failure(error))) + pixelHandler.handle(.failure(error), onDemand: startedByOnDemand) completionHandler(error) } - if startupOptions.startupMethod == .automaticOnDemand { + if startedByOnDemand { Task { // We add a 10 seconds delay when the VPN is started by // on-demand and there's an error, to avoid frenetic ON/OFF diff --git a/Sources/NetworkProtection/Pixels/VPNTunnelStartPixelHandler.swift b/Sources/NetworkProtection/Pixels/VPNTunnelStartPixelHandler.swift new file mode 100644 index 000000000..256dac247 --- /dev/null +++ b/Sources/NetworkProtection/Pixels/VPNTunnelStartPixelHandler.swift @@ -0,0 +1,100 @@ +// +// VPNTunnelStartPixelHandler.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation +import PixelKit + +/// This class handles firing the tunnel start attempts. +/// +/// The reason this logic is contained here, is that we were getting flooded by attempt pixels when an unattended system kept failing with +/// on-demand enabled. This class aims to confine these pixels to "sessions" of attempts, so to speak. +/// +final class VPNTunnelStartPixelHandler { + + typealias Event = PacketTunnelProvider.Event + typealias Step = PacketTunnelProvider.TunnelStartAttemptStep + + private static let canFireKey = "VPNTunnelStartPixelHandler.canFire" + private static let lastFireDateKey = "VPNTunnelStartPixelHandler.lastFireDate" + + private let userDefaults: UserDefaults + private let systemBootDate: Date + private let eventHandler: EventMapping + + init(eventHandler: EventMapping, + systemBootDate: Date = ProcessInfo.systemBootDate(), + userDefaults: UserDefaults) { + + self.userDefaults = userDefaults + self.systemBootDate = systemBootDate + self.eventHandler = eventHandler + } + + func handle(_ step: Step, onDemand: Bool) { + if shouldResumeFiring(onDemand: onDemand) { + canFire = true + } + + if canFire { + let event = Event.tunnelStartAttempt(step) + eventHandler.fire(event) + } + + switch step { + case .failure where onDemand == true: + // After firing an on-demand start failure, we always silence pixels + canFire = false + case .success: + // A success always restores firing + canFire = true + default: + break + } + } + + private func shouldResumeFiring(onDemand: Bool) -> Bool { + guard onDemand else { + return true + } + + return lastFireDate < systemBootDate + } + + // MARK: - User Defaults stored values + + var canFire: Bool { + get { + userDefaults.value(forKey: Self.canFireKey) as? Bool ?? true + } + + set { + userDefaults.setValue(newValue, forKey: Self.canFireKey) + } + } + + private var lastFireDate: Date { + let interval = userDefaults.value(forKey: Self.lastFireDateKey) as? TimeInterval ?? 0 + return Date(timeIntervalSinceReferenceDate: interval) + } + + private func updateLastFireDate() { + let interval = Date().timeIntervalSinceReferenceDate + userDefaults.setValue(interval, forKey: Self.lastFireDateKey) + } +} diff --git a/Sources/NetworkProtection/StartupOptions.swift b/Sources/NetworkProtection/StartupOptions.swift index f895c8fc8..16b63b978 100644 --- a/Sources/NetworkProtection/StartupOptions.swift +++ b/Sources/NetworkProtection/StartupOptions.swift @@ -47,6 +47,16 @@ struct StartupOptions { return "manually by the system" } } + + init(options: [String: Any]) { + if options[NetworkProtectionOptionKey.isOnDemand] as? Bool == true { + self = .automaticOnDemand + } else if options[NetworkProtectionOptionKey.activationAttemptId] != nil { + self = .manualByMainApp + } else { + self = .manualByTheSystem + } + } } /// Stored options are the options that the our network extension stores / remembers. @@ -113,17 +123,7 @@ struct StartupOptions { let enableTester: StoredOption init(options: [String: Any]) { - let startupMethod: StartupMethod = { - if options[NetworkProtectionOptionKey.isOnDemand] as? Bool == true { - return .automaticOnDemand - } else if options[NetworkProtectionOptionKey.activationAttemptId] != nil { - return .manualByMainApp - } else { - return .manualByTheSystem - } - }() - - self.startupMethod = startupMethod + self.startupMethod = StartupMethod(options: options) simulateError = options[NetworkProtectionOptionKey.tunnelFailureSimulation] as? Bool ?? false simulateCrash = options[NetworkProtectionOptionKey.tunnelFatalErrorCrashSimulation] as? Bool ?? false diff --git a/Tests/NetworkProtectionTests/Pixels/VPNTunnelStartPixelHandlerTests.swift b/Tests/NetworkProtectionTests/Pixels/VPNTunnelStartPixelHandlerTests.swift new file mode 100644 index 000000000..b768a3758 --- /dev/null +++ b/Tests/NetworkProtectionTests/Pixels/VPNTunnelStartPixelHandlerTests.swift @@ -0,0 +1,277 @@ +// +// VPNTunnelStartPixelHandlerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import XCTest +import Network +@testable import NetworkProtection + +final class VPNTunnelStartPixelHandlerTests: XCTestCase { + + private final class ValidationHandler { + private let beginFired: XCTestExpectation + private let successFired: XCTestExpectation + private let failureFired: XCTestExpectation + private let noOtherPixelFired: XCTestExpectation + + init(beginFired: XCTestExpectation, + successFired: XCTestExpectation, + failureFired: XCTestExpectation, + noOtherPixelFired: XCTestExpectation) { + + self.beginFired = beginFired + self.successFired = successFired + self.failureFired = failureFired + self.noOtherPixelFired = noOtherPixelFired + } + + private(set) lazy var eventHandler = EventMapping { [weak self] event, _, _, _ in + + guard let self else { + return + } + + switch event { + case .tunnelStartAttempt(let step): + switch step { + case .begin: + self.beginFired.fulfill() + case .success: + self.successFired.fulfill() + case .failure: + self.failureFired.fulfill() + } + default: + XCTFail("An unexpected pixel was fired") + } + } + } + + func makeUserDefaults() -> UserDefaults { + UserDefaults(suiteName: UUID().uuidString)! + } + + func makeFiredExpectation(description: String, count: Int = 1) -> XCTestExpectation { + let expectation = expectation(description: description) + expectation.expectedFulfillmentCount = count + expectation.assertForOverFulfill = true + return expectation + } + + func makeNotFiredExpectation(description: String) -> XCTestExpectation { + let expectation = expectation(description: description) + expectation.isInverted = true + return expectation + } + + // MARK: - Simple success + + func testManualStartSuccess() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeFiredExpectation(description: "Success pixel was fired") + let failureFiredExpectation = makeNotFiredExpectation(description: "Failure pixel was not fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let onDemand = false + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: onDemand) + handler.handle(.success, onDemand: onDemand) + + waitForExpectations(timeout: 0.1) + } + + func testOnDemandStartSuccess() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeFiredExpectation(description: "Success pixel was fired") + let failureFiredExpectation = makeNotFiredExpectation(description: "Failure pixel was not fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let onDemand = true + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: onDemand) + handler.handle(.success, onDemand: onDemand) + + waitForExpectations(timeout: 0.1) + } + + // MARK: - Simple failure + + func testManualStartFailure() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let onDemand = false + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: onDemand) + handler.handle(.failure(NSError()), onDemand: onDemand) + + waitForExpectations(timeout: 0.1) + } + + func testOnDemandStartFailure() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let onDemand = true + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: onDemand) + handler.handle(.failure(NSError()), onDemand: onDemand) + + waitForExpectations(timeout: 0.1) + } + + // MARK: - Several failures in a row + + func testManualAndOnDemandFailureBothReported() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired", count: 2) + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired", count: 2) + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: false) + handler.handle(.failure(NSError()), onDemand: false) + handler.handle(.begin, onDemand: true) + handler.handle(.failure(NSError()), onDemand: true) + + waitForExpectations(timeout: 0.1) + } + + func testSecondOnDemandFailureSilenced() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: true) + handler.handle(.failure(NSError()), onDemand: true) + handler.handle(.begin, onDemand: true) + handler.handle(.failure(NSError()), onDemand: true) + + waitForExpectations(timeout: 0.1) + } + + func testSecondOnDemandFailureNotSilencedAfterReboot() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired", count: 2) + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired", count: 2) + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + + handler.handle(.begin, onDemand: true) + handler.handle(.failure(NSError()), onDemand: true) + + let handlerAfterReboot = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: Date(), userDefaults: defaults) + + handlerAfterReboot.handle(.begin, onDemand: true) + handlerAfterReboot.handle(.failure(NSError()), onDemand: true) + + waitForExpectations(timeout: 0.1) + } + + // MARK: - Re-enabling firing + + func testManualAttemptsAlwaysFire() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + handler.canFire = false + + handler.handle(.begin, onDemand: false) + handler.handle(.failure(NSError()), onDemand: false) + + waitForExpectations(timeout: 0.1) + } + + func testOnDemandSuccessRestoresFiring() { + let beginFiredExpectation = makeFiredExpectation(description: "Begin pixel was fired") + let successFiredExpectation = makeNotFiredExpectation(description: "Success pixel was not fired") + let failureFiredExpectation = makeFiredExpectation(description: "Failure pixel was fired") + let noOtherPixelFiredExpectation = makeNotFiredExpectation(description: "No other pixel was fired") + + let validationHandler = ValidationHandler(beginFired: beginFiredExpectation, successFired: successFiredExpectation, failureFired: failureFiredExpectation, noOtherPixelFired: noOtherPixelFiredExpectation) + + let defaults = makeUserDefaults() + let bootDate = Date.distantPast + + let handler = VPNTunnelStartPixelHandler(eventHandler: validationHandler.eventHandler, systemBootDate: bootDate, userDefaults: defaults) + handler.canFire = false + + handler.handle(.begin, onDemand: true) + handler.handle(.success, onDemand: true) + handler.handle(.begin, onDemand: true) + handler.handle(.failure(NSError()), onDemand: true) + + waitForExpectations(timeout: 0.1) + } +}