Invoke VPNManager methods with a Profile object (#309)

The VPNConfiguration parameter is opaque and tightly coupled to
TunnelKit. Connecting to a Profile makes infinitely more sense, beyond
simplifying the VPNManager class. Configuration building is fully
delegated to the strategy (as it has to be).

- VPNManager takes Profile and produces VPNConfigurationParameters
- VPNManagerStrategy takes VPNConfigurationParameters (abstract)
- TunnelKitVPNManagerStrategy takes VPNConfigurationParameters and
produces TunnelKitVPNConfiguration internally
This commit is contained in:
Davide De Rosa 2023-05-28 09:56:51 +02:00 committed by GitHub
parent 36cad41529
commit 3a59ac7684
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 176 additions and 190 deletions

View File

@ -85,7 +85,7 @@ let package = Package(
.target(
name: "PassepartoutCore",
dependencies: [
.product(name: "GenericJSON", package: "generic-json-swift") // FIXME: arch, drop this
.product(name: "GenericJSON", package: "generic-json-swift")
]),
// MARK: App extensions

View File

@ -52,3 +52,9 @@ extension TimeInterval {
return str
}
}
extension TimeInterval {
public var dispatchTimeInterval: DispatchTimeInterval {
.nanoseconds(Int(self * Double(NSEC_PER_SEC)))
}
}

View File

@ -43,4 +43,20 @@ extension Profile {
self.configuration = configuration
}
}
init(_ id: UUID = UUID(), name: String, configuration: OpenVPN.Configuration) {
let header = Header(
uuid: id,
name: name,
providerName: nil
)
self.init(header, configuration: configuration)
}
public init(_ header: Header, configuration: OpenVPN.Configuration) {
self.header = header
currentVPNProtocol = .openVPN
host = Host()
host?.ovpnSettings = OpenVPNSettings(configuration: configuration)
}
}

View File

@ -39,4 +39,20 @@ extension Profile {
self.configuration = configuration
}
}
init(_ id: UUID = UUID(), name: String, configuration: WireGuard.Configuration) {
let header = Header(
uuid: id,
name: name,
providerName: nil
)
self.init(header, configuration: configuration)
}
public init(_ header: Header, configuration: WireGuard.Configuration) {
self.header = header
currentVPNProtocol = .wireGuard
host = Host()
host?.wgSettings = WireGuardSettings(configuration: configuration)
}
}

View File

@ -25,8 +25,6 @@
import Foundation
import PassepartoutCore
import TunnelKitOpenVPN
import TunnelKitWireGuard
public protocol ProfileSubtype {
var vpnProtocols: [VPNProtocolType] { get }
@ -59,24 +57,6 @@ public struct Profile: Identifiable, Codable, Equatable {
currentVPNProtocol = .openVPN
}
init(_ id: UUID = UUID(), name: String, configuration: OpenVPN.Configuration) {
let header = Header(
uuid: id,
name: name,
providerName: nil
)
self.init(header, configuration: configuration)
}
init(_ id: UUID = UUID(), name: String, configuration: WireGuard.Configuration) {
let header = Header(
uuid: id,
name: name,
providerName: nil
)
self.init(header, configuration: configuration)
}
init(_ id: UUID = UUID(), name: String, provider: Profile.Provider) {
let header = Header(
uuid: id,
@ -86,20 +66,6 @@ public struct Profile: Identifiable, Codable, Equatable {
self.init(header, provider: provider)
}
public init(_ header: Header, configuration: OpenVPN.Configuration) {
self.header = header
currentVPNProtocol = .openVPN
host = Host()
host?.ovpnSettings = OpenVPNSettings(configuration: configuration)
}
public init(_ header: Header, configuration: WireGuard.Configuration) {
self.header = header
currentVPNProtocol = .wireGuard
host = Host()
host?.wgSettings = WireGuardSettings(configuration: configuration)
}
public init(_ header: Header, provider: Profile.Provider) {
guard let firstVPNProtocol = provider.vpnSettings.keys.first else {
fatalError("No VPN protocols defined in provider")

View File

@ -1,29 +0,0 @@
//
// VPNConfiguration.swift
// Passepartout
//
// Created by Davide De Rosa on 6/22/22.
// Copyright (c) 2023 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import TunnelKitManager
public typealias VPNConfiguration = (neConfiguration: NetworkExtensionConfiguration, neExtra: NetworkExtensionExtra)

View File

@ -25,11 +25,13 @@
import Foundation
import PassepartoutCore
import TunnelKitManager
import PassepartoutProviders
public struct VPNConfigurationParameters {
public let profile: Profile
public let providerManager: ProviderManager
public var title: String {
profile.header.name
}
@ -52,12 +54,14 @@ public struct VPNConfigurationParameters {
init(
_ profile: Profile,
providerManager: ProviderManager,
preferences: VPNPreferences,
passwordReference: Data?,
withNetworkSettings: Bool,
withCustomRules: Bool
) {
self.profile = profile
self.providerManager = providerManager
self.preferences = preferences
self.passwordReference = passwordReference
self.withNetworkSettings = withNetworkSettings

View File

@ -65,10 +65,9 @@ extension VPNManager {
if let newPassword {
profile.account.password = newPassword
}
let cfg = try vpnConfiguration(withProfile: profile)
profileManager.activateProfile(profile)
await reconnect(cfg)
await reconnect(profile)
return profile
}
@ -99,14 +98,13 @@ extension VPNManager {
pp_log.info("Connecting to: \(profile.logDescription) @ \(newServer.logDescription)")
profile.setProviderServer(newServer)
let cfg = try vpnConfiguration(withProfile: profile)
profileManager.activateProfile(profile)
guard !profileManager.isCurrentProfile(profileId) else {
pp_log.debug("Active profile is current, will reconnect via observation")
return profile
}
await reconnect(cfg)
await reconnect(profile)
return profile
}
@ -118,13 +116,12 @@ extension VPNManager {
pp_log.info("Modifying active profile")
block(&profile)
let cfg = try vpnConfiguration(withProfile: profile)
profileManager.activateProfile(profile)
guard !profileManager.isCurrentProfile(profile.id) else {
pp_log.debug("Active profile is current, will reinstate via observation")
return
}
await reinstate(cfg)
await reinstate(profile)
}
}

View File

@ -84,16 +84,28 @@ public final class VPNManager: ObservableObject {
currentState = ObservableVPNState()
}
func reinstate(_ configuration: VPNConfiguration) async {
func reinstate(_ profile: Profile) async {
pp_log.info("Reinstating VPN")
clearLastError()
await strategy.reinstate(configuration: configuration)
do {
let parameters = try vpnConfigurationParameters(withProfile: profile)
await strategy.reinstate(parameters)
} catch {
pp_log.error("Unable to build configuration: \(error)")
configurationError.send((profile, error))
}
}
func reconnect(_ configuration: VPNConfiguration) async {
func reconnect(_ profile: Profile) async {
pp_log.info("Reconnecting VPN (with new configuration)")
clearLastError()
await strategy.connect(configuration: configuration)
do {
let parameters = try vpnConfigurationParameters(withProfile: profile)
await strategy.connect(parameters)
} catch {
pp_log.error("Unable to build configuration: \(error)")
configurationError.send((profile, error))
}
}
public func reconnect() async {
@ -139,7 +151,11 @@ extension VPNManager {
}
private func observeStrategy() {
strategy.observe(into: MutableObservableVPNState(currentState))
strategy.observe(into: MutableObservableVPNState(currentState)) { profile, error in
// UI is certainly interested in configuration errors
self.configurationError.send((profile, error))
}
}
private func observeProfileManager() {
@ -233,72 +249,51 @@ extension VPNManager {
guard isHandled else {
return
}
guard let cfg = vpnConfigurationWithCurrentProfile() else {
guard profileManager.isActiveProfile(newProfile.id) else {
pp_log.info("Skipping VPN reaction, current profile is not active")
return
}
if shouldReconnect {
await reconnect(cfg)
await reconnect(newProfile)
} else {
await reinstate(cfg)
await reinstate(newProfile)
}
}
}
// MARK: Configuration
extension VPNManager {
func vpnConfigurationWithCurrentProfile() -> VPNConfiguration? {
do {
guard profileManager.isCurrentProfileActive() else {
pp_log.info("Skipping VPN configuration, current profile is not active")
return nil
private extension VPNManager {
func vpnConfigurationParameters(withProfile profile: Profile) throws -> VPNConfigurationParameters {
if profile.requiresCredentials {
guard !profile.account.isEmpty else {
throw PassepartoutError.missingAccount
}
return try vpnConfiguration(withProfile: profileManager.currentProfile.value)
} catch {
return nil
}
}
func vpnConfiguration(withProfile profile: Profile) throws -> VPNConfiguration {
do {
if profile.requiresCredentials {
guard !profile.account.isEmpty else {
throw PassepartoutError.missingAccount
}
// specific provider customizations
var newPassword: String?
if let providerName = profile.providerName {
switch providerName {
case .mullvad:
newPassword = "m"
default:
break
}
// specific provider customizations
var newPassword: String?
if let providerName = profile.providerName {
switch providerName {
case .mullvad:
newPassword = "m"
default:
break
}
}
// IMPORTANT: must commit password to keychain (tunnel needs a password reference)
profileManager.savePassword(forProfile: profile, newPassword: newPassword)
let parameters = VPNConfigurationParameters(
profile,
preferences: vpnPreferences,
passwordReference: profileManager.passwordReference(forProfile: profile),
withNetworkSettings: isNetworkSettingsSupported(),
withCustomRules: isOnDemandRulesSupported()
)
return try strategy.vpnConfiguration(parameters, providerManager: providerManager)
} catch {
pp_log.error("Unable to build VPNConfiguration: \(error)")
// UI is certainly interested in configuration errors
configurationError.send((profile, error))
throw error
}
// IMPORTANT: must commit password to keychain (tunnel needs a password reference)
profileManager.savePassword(forProfile: profile, newPassword: newPassword)
return VPNConfigurationParameters(
profile,
providerManager: providerManager,
preferences: vpnPreferences,
passwordReference: profileManager.passwordReference(forProfile: profile),
withNetworkSettings: isNetworkSettingsSupported(),
withCustomRules: isOnDemandRulesSupported()
)
}
}

View File

@ -29,11 +29,11 @@ import PassepartoutCore
import PassepartoutProviders
public protocol VPNManagerStrategy {
func observe(into state: MutableObservableVPNState)
func observe(into state: MutableObservableVPNState, onConfigurationError: @escaping (Profile, Error) -> Void)
func reinstate(configuration: VPNConfiguration) async
func reinstate(_ parameters: VPNConfigurationParameters) async
func connect(configuration: VPNConfiguration) async
func connect(_ parameters: VPNConfigurationParameters) async
func reconnect() async
@ -44,9 +44,4 @@ public protocol VPNManagerStrategy {
func serverConfiguration(forProtocol vpnProtocol: VPNProtocolType) -> Any?
func debugLogURL(forProtocol vpnProtocol: VPNProtocolType) -> URL?
func vpnConfiguration(
_ parameters: VPNConfigurationParameters,
providerManager: ProviderManager
) throws -> VPNConfiguration
}

View File

@ -30,7 +30,7 @@ import TunnelKitManager
import TunnelKitOpenVPN
extension Profile.OpenVPNSettings: TunnelKitConfigurationProviding {
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> VPNConfiguration {
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> TunnelKitVPNConfiguration {
var customBuilder = configuration.builder()
// tolerate widest range of certificates

View File

@ -30,7 +30,7 @@ import TunnelKitManager
import TunnelKitWireGuard
extension Profile.WireGuardSettings: TunnelKitConfigurationProviding {
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> VPNConfiguration {
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> TunnelKitVPNConfiguration {
var customBuilder = configuration.builder()
// network settings

View File

@ -33,8 +33,10 @@ import TunnelKitCore
import TunnelKitManager
import TunnelKitOpenVPNCore
typealias TunnelKitVPNConfiguration = (neConfiguration: NetworkExtensionConfiguration, neExtra: NetworkExtensionExtra)
protocol TunnelKitConfigurationProviding {
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> VPNConfiguration
func tunnelKitConfiguration(_ appGroup: String, parameters: VPNConfigurationParameters) throws -> TunnelKitVPNConfiguration
}
public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy where VPNType.Configuration == NetworkExtensionConfiguration, VPNType.Extra == NetworkExtensionExtra {
@ -49,8 +51,6 @@ public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy
}
}
private let reconnectionSeconds = 2
private let appGroup: String
private let tunnelBundleIdentifier: (VPNProtocolType) -> String
@ -59,12 +59,16 @@ public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy
private let vpn: VPNType
private let reconnectionInterval: TimeInterval
private let dataCountInterval: TimeInterval
// MARK: State
private var currentState: MutableObservableVPNState?
private var onConfigurationError: ((Profile, Error) -> Void)?
private let vpnState = CurrentValueSubject<AtomicState, Never>(.init())
private var dataCountTimer: AnyCancellable?
@ -79,6 +83,7 @@ public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy
appGroup: String,
tunnelBundleIdentifier: @escaping (VPNProtocolType) -> String,
vpn: VPNType,
reconnectionInterval: TimeInterval = 2.0,
dataCountInterval: TimeInterval = 3.0
) {
self.appGroup = appGroup
@ -88,6 +93,7 @@ public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy
}
self.defaults = defaults
self.vpn = vpn
self.reconnectionInterval = reconnectionInterval
self.dataCountInterval = dataCountInterval
registerNotification(withName: VPNNotification.didReinstall) {
@ -115,8 +121,9 @@ public final class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy
// MARK: Actions
extension TunnelKitVPNManagerStrategy {
public func observe(into state: MutableObservableVPNState) {
public func observe(into state: MutableObservableVPNState, onConfigurationError: @escaping (Profile, Error) -> Void) {
currentState = state
self.onConfigurationError = onConfigurationError
// use this to drop redundant NE notifications
vpnState
@ -128,7 +135,10 @@ extension TunnelKitVPNManagerStrategy {
}.store(in: &cancellables)
}
public func reinstate(configuration: VPNConfiguration) async {
public func reinstate(_ parameters: VPNConfigurationParameters) async {
guard let configuration = try? vpnConfiguration(withParameters: parameters) else {
return
}
guard let vpnType = configuration.neConfiguration as? VPNProtocolProviding else {
fatalError("Configuration must implement VPNProtocolProviding")
}
@ -148,7 +158,10 @@ extension TunnelKitVPNManagerStrategy {
}
}
public func connect(configuration: VPNConfiguration) async {
public func connect(_ parameters: VPNConfigurationParameters) async {
guard let configuration = try? vpnConfiguration(withParameters: parameters) else {
return
}
guard let vpnType = configuration.neConfiguration as? VPNProtocolProviding else {
fatalError("Configuration must implement VPNProtocolProviding")
}
@ -162,7 +175,7 @@ extension TunnelKitVPNManagerStrategy {
bundleIdentifier,
configuration: configuration.neConfiguration,
extra: configuration.neExtra,
after: .seconds(reconnectionSeconds)
after: reconnectionInterval.dispatchTimeInterval
)
} catch {
pp_log.error("Unable to connect: \(error)")
@ -171,7 +184,7 @@ extension TunnelKitVPNManagerStrategy {
public func reconnect() async {
try? await vpn.reconnect(
after: .seconds(reconnectionSeconds)
after: reconnectionInterval.dispatchTimeInterval
)
}
@ -192,8 +205,8 @@ extension TunnelKitVPNManagerStrategy {
// MARK: Notifications
extension TunnelKitVPNManagerStrategy {
private func onVPNReinstall(_ notification: Notification) {
private extension TunnelKitVPNManagerStrategy {
func onVPNReinstall(_ notification: Notification) {
guard isRelevantNotification(notification) else {
return
}
@ -204,7 +217,7 @@ extension TunnelKitVPNManagerStrategy {
))
}
private func onVPNStatus(_ notification: Notification) {
func onVPNStatus(_ notification: Notification) {
// assume first notified identifier to be the relevant one
// in order to restore VPN status on app launch
@ -239,7 +252,7 @@ extension TunnelKitVPNManagerStrategy {
currentState?.lastError = error
}
private func onVPNFail(_ notification: Notification) {
func onVPNFail(_ notification: Notification) {
vpnState.send(AtomicState(
isEnabled: notification.vpnIsEnabled,
vpnStatus: vpnState.value.vpnStatus
@ -247,7 +260,7 @@ extension TunnelKitVPNManagerStrategy {
currentState?.lastError = notification.vpnError
}
private func isRelevantNotification(_ notification: Notification) -> Bool {
func isRelevantNotification(_ notification: Notification) -> Bool {
guard let notificationTunnelIdentifier = notification.vpnBundleIdentifier else {
return false
}
@ -260,7 +273,7 @@ extension TunnelKitVPNManagerStrategy {
// MARK: Data count
private func onDataCount(_: Date) {
func onDataCount(_: Date) {
switch vpnState.value.vpnStatus {
case .connected:
guard let currentDataCount = currentDataCount else {
@ -273,7 +286,7 @@ extension TunnelKitVPNManagerStrategy {
}
}
private func startCountingData() {
func startCountingData() {
guard dataCountTimer == nil else {
return
}
@ -284,7 +297,7 @@ extension TunnelKitVPNManagerStrategy {
}
}
private func stopCountingData() {
func stopCountingData() {
dataCountTimer?.cancel()
dataCountTimer = nil
@ -292,6 +305,45 @@ extension TunnelKitVPNManagerStrategy {
}
}
// MARK: Configuration
private extension TunnelKitVPNManagerStrategy {
func vpnConfiguration(withParameters parameters: VPNConfigurationParameters) throws -> TunnelKitVPNConfiguration {
let profile = parameters.profile
do {
switch profile.currentVPNProtocol {
case .openVPN:
let settings: Profile.OpenVPNSettings
if profile.isProvider {
settings = try profile.providerOpenVPNSettings(withManager: parameters.providerManager)
} else {
guard let hostSettings = profile.hostOpenVPNSettings else {
fatalError("Profile currentVPNProtocol is OpenVPN, but host has no OpenVPN settings")
}
settings = hostSettings
}
return try settings.tunnelKitConfiguration(appGroup, parameters: parameters)
case .wireGuard:
let settings: Profile.WireGuardSettings
if profile.isProvider {
settings = try profile.providerWireGuardSettings(withManager: parameters.providerManager)
} else {
guard let hostSettings = profile.hostWireGuardSettings else {
fatalError("Profile currentVPNProtocol is WireGuard, but host has no WireGuard settings")
}
settings = hostSettings
}
return try settings.tunnelKitConfiguration(appGroup, parameters: parameters)
}
} catch {
pp_log.error("Unable to build TunnelKitVPNConfiguration: \(error)")
onConfigurationError?(profile, error)
throw error
}
}
}
// MARK: Pulled
extension TunnelKitVPNManagerStrategy {
@ -314,10 +366,12 @@ extension TunnelKitVPNManagerStrategy {
return defaults.wireGuardURLForDebugLog(appGroup: appGroup)
}
}
}
// MARK: Callbacks
// MARK: Callbacks
private func lastError(withBundleIdentifier bundleIdentifier: String?) -> Error? {
private extension TunnelKitVPNManagerStrategy {
func lastError(withBundleIdentifier bundleIdentifier: String?) -> Error? {
switch bundleIdentifier {
case tunnelBundleIdentifier(.openVPN):
return defaults.openVPNLastError
@ -330,7 +384,7 @@ extension TunnelKitVPNManagerStrategy {
}
}
private var currentDataCount: DataCount? {
var currentDataCount: DataCount? {
switch currentBundleIdentifier {
case tunnelBundleIdentifier(.openVPN):
return defaults.openVPNDataCount
@ -340,36 +394,3 @@ extension TunnelKitVPNManagerStrategy {
}
}
}
// MARK: Configuration
extension TunnelKitVPNManagerStrategy {
public func vpnConfiguration(_ parameters: VPNConfigurationParameters, providerManager: ProviderManager) throws -> VPNConfiguration {
let profile = parameters.profile
switch profile.currentVPNProtocol {
case .openVPN:
let settings: Profile.OpenVPNSettings
if profile.isProvider {
settings = try profile.providerOpenVPNSettings(withManager: providerManager)
} else {
guard let hostSettings = profile.hostOpenVPNSettings else {
fatalError("Profile currentVPNProtocol is OpenVPN, but host has no OpenVPN settings")
}
settings = hostSettings
}
return try settings.tunnelKitConfiguration(appGroup, parameters: parameters)
case .wireGuard:
let settings: Profile.WireGuardSettings
if profile.isProvider {
settings = try profile.providerWireGuardSettings(withManager: providerManager)
} else {
guard let hostSettings = profile.hostWireGuardSettings else {
fatalError("Profile currentVPNProtocol is WireGuard, but host has no WireGuard settings")
}
settings = hostSettings
}
return try settings.tunnelKitConfiguration(appGroup, parameters: parameters)
}
}
}

View File

@ -38,7 +38,6 @@ final class ProvidersTests: XCTestCase {
private var cancellables: Set<AnyCancellable> = []
override func setUp() {
let model = NSManagedObjectModel.mergedModel(from: [.module])!
persistence = ProvidersPersistence(withName: "ProvidersTests", cloudKit: false, author: nil)
let remoteStrategy = APIRemoteProvidersStrategy(