From e514ade036b3792a940b2d682f6494c623123480 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 12 Nov 2024 16:42:19 +0100 Subject: [PATCH] Add logic to migrate v2 profiles (#854) Will add UI separately. Fixes #642 --- .github/workflows/test.yml | 5 +- .gitignore | 1 + Gemfile.lock | 12 +- Passepartout/App/AppDelegate.swift | 2 +- .../xcschemes/Library-Package.xcscheme | 24 ++ Passepartout/Library/Package.resolved | 2 +- Passepartout/Library/Package.swift | 14 +- .../Domain/MigratableProfile.swift | 40 +++ .../Sources/CommonLibrary/Shared.swift | 2 + .../Business/CoreDataPersistentStore.swift | 21 +- .../LegacyV2/CDProfileRepositoryV2.swift | 86 +++++-- .../LegacyV2/{ => Domain}/CDProfile.swift | 0 .../Sources/LegacyV2/Domain/MapperV2.swift | 186 ++++++++++++++ .../Sources/LegacyV2/Domain/Network.swift | 159 ++++++++++++ .../LegacyV2/Domain/Profile+Account.swift | 58 +++++ .../LegacyV2/Domain/Profile+Header.swift | 70 ++++++ .../LegacyV2/Domain/Profile+Host.swift | 34 +++ .../Domain/Profile+NetworkSettings.swift | 53 ++++ .../LegacyV2/Domain/Profile+OnDemand.swift | 55 +++++ .../Domain/Profile+OpenVPNSettings.swift | 61 +++++ .../LegacyV2/Domain/Profile+Provider.swift | 58 +++++ .../Domain/Profile+WireGuardSettings.swift | 79 ++++++ .../Sources/LegacyV2/Domain/Profile.swift | 118 +++++++++ .../Library/Sources/LegacyV2/LegacyV2.swift | 47 +++- .../Profiles.xcdatamodel/contents | 2 +- .../Tests/LegacyV2Tests/LegacyV2Tests.swift | 232 ++++++++++++++++++ .../Tests/LegacyV2Tests/MapperV2Tests.swift | 60 +++++ .../LegacyV2Tests/Resources/Profiles.sqlite | Bin 0 -> 311296 bytes Passepartout/Passepartout.xctestplan | 7 + fastlane/.env | 1 + fastlane/Fastfile | 9 + 31 files changed, 1454 insertions(+), 44 deletions(-) create mode 100644 Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift rename Passepartout/Library/Sources/LegacyV2/{ => Domain}/CDProfile.swift (100%) create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/MapperV2.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Network.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+Account.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+Header.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+Host.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+NetworkSettings.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+OnDemand.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+OpenVPNSettings.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+Provider.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile+WireGuardSettings.swift create mode 100644 Passepartout/Library/Sources/LegacyV2/Domain/Profile.swift create mode 100644 Passepartout/Library/Tests/LegacyV2Tests/LegacyV2Tests.swift create mode 100644 Passepartout/Library/Tests/LegacyV2Tests/MapperV2Tests.swift create mode 100644 Passepartout/Library/Tests/LegacyV2Tests/Resources/Profiles.sqlite diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c0596e2..35446d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,5 +25,6 @@ jobs: access_token: ${{ secrets.ACCESS_TOKEN }} - name: Run tests run: | - cd Passepartout/Library - swift test + #cd Passepartout/Library + #swift test + bundle exec fastlane test diff --git a/.gitignore b/.gitignore index b72852fe..e6249374 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ default.profraw .env.secret* *.storekit tmp +*.sqlite-* diff --git a/Gemfile.lock b/Gemfile.lock index f3394373..06460bdf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,8 +58,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.1001.0) - aws-sdk-core (3.211.0) + aws-partitions (1.1004.0) + aws-sdk-core (3.212.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -67,7 +67,7 @@ GEM aws-sdk-kms (1.95.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) + aws-sdk-s3 (1.170.1) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -121,7 +121,7 @@ GEM fastlane-plugin-translate_gpt (0.1.8.2) loco_strings (~> 0.1.4.1) ruby-openai (~> 3.7) - fastlane-plugin-versioning (0.6.0) + fastlane-plugin-versioning (0.7.0) fastlane-sirp (1.0.0) sysrandom (~> 1.0) gh_inspector (1.1.3) @@ -170,7 +170,7 @@ GEM multi_xml (>= 0.5.2) httpclient (2.8.3) jmespath (1.6.2) - json (2.7.5) + json (2.8.1) jwt (2.9.3) base64 loco_strings (0.1.4.1) @@ -190,7 +190,7 @@ GEM racc (~> 1.4) nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) - optparse (0.5.0) + optparse (0.6.0) os (1.1.4) plist (3.7.1) public_suffix (6.0.1) diff --git a/Passepartout/App/AppDelegate.swift b/Passepartout/App/AppDelegate.swift index 9e6c0e53..0c33e2df 100644 --- a/Passepartout/App/AppDelegate.swift +++ b/Passepartout/App/AppDelegate.swift @@ -36,5 +36,5 @@ final class AppDelegate: NSObject { func configure(with uiConfiguring: UILibraryConfiguring) { UILibrary(uiConfiguring) .configure(with: context) - } + } } diff --git a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme b/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme index 082343e1..d3e6193f 100644 --- a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme +++ b/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme @@ -91,6 +91,20 @@ ReferencedContainer = "container:"> + + + + + + + + . +// + +import Foundation + +public struct MigratableProfile: Sendable { + public let id: UUID + + public let name: String + + public let lastUpdate: Date? + + public init(id: UUID, name: String, lastUpdate: Date?) { + self.id = id + self.name = name + self.lastUpdate = lastUpdate + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Shared.swift b/Passepartout/Library/Sources/CommonLibrary/Shared.swift index 7104b8f2..cc591493 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Shared.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Shared.swift @@ -32,6 +32,8 @@ extension LoggerDestination { public enum App { public static let iap = LoggerDestination(category: "app.iap") + public static let migration = LoggerDestination(category: "app.migration") + public static let profiles = LoggerDestination(category: "app.profiles") } } diff --git a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift index 335a24db..e746410d 100644 --- a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift +++ b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataPersistentStore.swift @@ -35,13 +35,14 @@ public protocol CoreDataPersistentStoreLogger { } public final class CoreDataPersistentStore { - private let logger: CoreDataPersistentStoreLogger + private let logger: CoreDataPersistentStoreLogger? private let container: NSPersistentContainer public convenience init( - logger: CoreDataPersistentStoreLogger, + logger: CoreDataPersistentStoreLogger? = nil, containerName: String, + baseURL: URL? = nil, model: NSManagedObjectModel, cloudKitIdentifier: String?, author: String? @@ -49,10 +50,14 @@ public final class CoreDataPersistentStore { let container: NSPersistentContainer if let cloudKitIdentifier { container = NSPersistentCloudKitContainer(name: containerName, managedObjectModel: model) - logger.debug("Set up CloudKit container (\(cloudKitIdentifier)): \(containerName)") + logger?.debug("Set up CloudKit container (\(cloudKitIdentifier)): \(containerName)") } else { container = NSPersistentContainer(name: containerName, managedObjectModel: model) - logger.debug("Set up local container: \(containerName)") + logger?.debug("Set up local container: \(containerName)") + } + if let baseURL { + let url = baseURL.appending(component: "\(containerName).sqlite") + container.persistentStoreDescriptions = [.init(url: url)] } self.init( logger: logger, @@ -63,7 +68,7 @@ public final class CoreDataPersistentStore { } private init( - logger: CoreDataPersistentStoreLogger, + logger: CoreDataPersistentStoreLogger?, container: NSPersistentContainer, cloudKitIdentifier: String?, author: String? @@ -74,7 +79,7 @@ public final class CoreDataPersistentStore { guard let desc = container.persistentStoreDescriptions.first else { fatalError("Unable to read persistent store description") } - logger.debug("Container description: \(desc)") + logger?.debug("Container description: \(desc)") // optional container identifier for CloudKit, first in entitlements otherwise if let cloudKitIdentifier { @@ -98,7 +103,7 @@ public final class CoreDataPersistentStore { container.viewContext.automaticallyMergesChangesFromParent = true if let author { - logger.debug("Setting transaction author: \(author)") + logger?.debug("Setting transaction author: \(author)") container.viewContext.transactionAuthor = author } } @@ -141,7 +146,7 @@ extension CoreDataPersistentStore { try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: $0, options: nil) } } catch { - logger.warning("Unable to truncate persistent store: \(error)") + logger?.warning("Unable to truncate persistent store: \(error)") } } } diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift index 1f867f12..99cf3019 100644 --- a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CommonLibrary import CoreData import Foundation import PassepartoutKit @@ -41,31 +42,78 @@ final class CDProfileRepositoryV2 { self.context = context } - // FIXME: #642, migrate profiles properly - func migratedProfiles() async throws -> [Profile] { + func migratableProfiles() async throws -> [MigratableProfile] { + try await fetchProfiles( + prefetch: { + $0.propertiesToFetch = ["uuid", "name", "lastUpdate"] + }, + map: { + $0.compactMap { + guard $0.value.encryptedJSON ?? $0.value.json != nil else { + pp_log(.App.migration, .error, "Unable to migrate profile \($0.key): missing JSON") + return nil + } + return MigratableProfile( + id: $0.key, + name: $0.value.name ?? $0.key.uuidString, + lastUpdate: $0.value.lastUpdate + ) + } + } + ) + } + + func profiles() async throws -> [ProfileV2] { + let decoder = JSONDecoder() + return try await fetchProfiles( + map: { + $0.compactMap { + guard let json = $0.value.encryptedJSON ?? $0.value.json else { + pp_log(.App.migration, .error, "Unable to migrate profile \($0.key): missing JSON") + return nil + } + do { + return try decoder.decode(ProfileV2.self, from: json) + } catch { + pp_log(.App.migration, .error, "Unable to migrate profile \($0.key): \(error)") + return nil + } + } + } + ) + } +} + +private extension CDProfileRepositoryV2 { + func fetchProfiles( + prefetch: ((NSFetchRequest) -> Void)? = nil, + map: @escaping ([UUID: CDProfile]) -> [T] + ) async throws -> [T] { try await context.perform { [weak self] in guard let self else { return [] } - do { - let request = CDProfile.fetchRequest() - let existing = try context.fetch(request) -// existing.forEach { -// guard let json = $0.encryptedJSON, -// let string = String(data: json, encoding: .utf8) else { -// return -// } -// print(">>> \(string)") -// } - return existing.compactMap { - guard let name = $0.name else { - return nil - } - return try? Profile.Builder(name: name).tryBuild() + + let request = CDProfile.fetchRequest() + request.sortDescriptors = [ + .init(key: "lastUpdate", ascending: false) + ] + prefetch?(request) + let existing = try context.fetch(request) + + var deduped: [UUID: CDProfile] = [:] + existing.forEach { + guard let uuid = $0.uuid else { + return } - } catch { - throw error + guard !deduped.keys.contains(uuid) else { + pp_log(.App.migration, .info, "Skip older duplicate of profile \(uuid)") + return + } + deduped[uuid] = $0 } + + return map(deduped) } } } diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfile.swift b/Passepartout/Library/Sources/LegacyV2/Domain/CDProfile.swift similarity index 100% rename from Passepartout/Library/Sources/LegacyV2/CDProfile.swift rename to Passepartout/Library/Sources/LegacyV2/Domain/CDProfile.swift diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/MapperV2.swift b/Passepartout/Library/Sources/LegacyV2/Domain/MapperV2.swift new file mode 100644 index 00000000..adb85d2a --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/MapperV2.swift @@ -0,0 +1,186 @@ +// +// MapperV2.swift +// Passepartout +// +// Created by Davide De Rosa on 11/12/24. +// Copyright (c) 2024 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 . +// + +import Foundation +import PassepartoutKit + +struct MapperV2 { + func toProfileV3(_ v2: ProfileV2) throws -> Profile { + var builder = Profile.Builder(id: v2.id) + var modules: [Module] = [] + + builder.name = v2.header.name + builder.attributes.lastUpdate = v2.header.lastUpdate + + modules.append(toOnDemandModule(v2.onDemand)) + + if let provider = v2.provider { + if let module = try toProviderModule(provider) { + let providerId = ProviderID(rawValue: provider.name) + modules.append(module) + builder.setProviderId(providerId, forModuleWithId: module.id) + } + } else if let ovpn = v2.host?.ovpnSettings { + modules.append(try toOpenVPNModule(ovpn)) + } else if let wg = v2.host?.wgSettings { + modules.append(try toWireGuardModule(wg)) + } + + try toNetworkModules(v2.networkSettings).forEach { + modules.append($0) + } + + builder.modules = modules + builder.activeModulesIds = Set(modules.map(\.id)) + return try builder.tryBuild() + } +} + +extension MapperV2 { + func toOnDemandModule(_ v2: ProfileV2.OnDemand) -> OnDemandModule { + var builder = OnDemandModule.Builder() + builder.isEnabled = v2.isEnabled + switch v2.policy { + case .any: + builder.policy = .any + case .excluding: + builder.policy = .excluding + case .including: + builder.policy = .including + } + builder.withSSIDs = v2.withSSIDs + builder.withOtherNetworks = Set(v2.withOtherNetworks.map { + switch $0 { + case .ethernet: + return .ethernet + case .mobile: + return .mobile + } + }) + return builder.tryBuild() + } +} + +extension MapperV2 { + func toOpenVPNModule(_ v2: ProfileV2.OpenVPNSettings) throws -> OpenVPNModule { + var builder = OpenVPNModule.Builder() + builder.configurationBuilder = v2.configuration.builder() + builder.credentials = v2.account.map(toOpenVPNCredentials) + return try builder.tryBuild() + } + + func toOpenVPNCredentials(_ v2: ProfileV2.Account) -> OpenVPN.Credentials { + OpenVPN.Credentials.Builder(username: v2.username, password: v2.password) + .build() + } + + func toWireGuardModule(_ v2: ProfileV2.WireGuardSettings) throws -> WireGuardModule { + var builder = WireGuardModule.Builder() + builder.configurationBuilder = v2.configuration.configuration.builder() + return try builder.tryBuild() + } +} + +extension MapperV2 { + func toProviderModule(_ v2: ProfileV2.Provider) throws -> OpenVPNModule? { + assert(v2.vpnSettings.count == 1) + guard let entry = v2.vpnSettings.first else { + return nil + } + assert(entry.key == .openVPN) + let settings = entry.value + + var builder = OpenVPNModule.Builder() + builder.credentials = settings.account.map(toOpenVPNCredentials) + return try builder.tryBuild() + } +} + +extension MapperV2 { + func toNetworkModules(_ v2: ProfileV2.NetworkSettings) throws -> [Module] { + var modules: [Module] = [] + if v2.dns.choice == .manual { + modules.append(try toDNSModule(v2.dns)) + } + if v2.proxy.choice == .manual { + modules.append(try toHTTPProxyModule(v2.proxy)) + } + if v2.gateway.choice == .manual || v2.mtu.choice == .manual { + modules.append(try toIPModule(v2.gateway, v2MTU: v2.mtu)) + } + return modules + } + + func toDNSModule(_ v2: Network.DNSSettings) throws -> DNSModule { + var builder = DNSModule.Builder() + builder.protocolType = v2.dnsProtocol ?? .cleartext + builder.servers = v2.dnsServers ?? [] + builder.domainName = v2.dnsDomain + builder.searchDomains = v2.dnsSearchDomains + builder.dohURL = v2.dnsHTTPSURL?.absoluteString ?? "" + builder.dotHostname = v2.dnsTLSServerName ?? "" + return try builder.tryBuild() + } + + func toHTTPProxyModule(_ v2: Network.ProxySettings) throws -> HTTPProxyModule { + var builder = HTTPProxyModule.Builder() + builder.address = v2.proxyAddress ?? "" + builder.port = v2.proxyPort ?? 0 + builder.secureAddress = v2.proxyAddress ?? "" + builder.securePort = v2.proxyPort ?? 0 + builder.pacURLString = v2.proxyAutoConfigurationURL?.absoluteString ?? "" + builder.bypassDomains = v2.proxyBypassDomains ?? [] + return try builder.tryBuild() + } + + func toIPModule(_ v2Gateway: Network.GatewaySettings?, v2MTU: Network.MTUSettings?) throws -> IPModule { + var builder = IPModule.Builder() + + if let v2Gateway, v2Gateway.choice == .manual { + let defaultRoute = Route(defaultWithGateway: nil) + + if v2Gateway.isDefaultIPv4 { + builder.ipv4 = IPSettings(subnet: nil) + .including(routes: [defaultRoute]) + } else { + builder.ipv4 = IPSettings(subnet: nil) + .excluding(routes: [defaultRoute]) + } + + if v2Gateway.isDefaultIPv6 { + builder.ipv6 = IPSettings(subnet: nil) + .including(routes: [defaultRoute]) + } else { + builder.ipv6 = IPSettings(subnet: nil) + .excluding(routes: [defaultRoute]) + } + } + if let v2MTU, v2MTU.choice == .manual { + builder.mtu = v2MTU.mtuBytes + } + + return builder.tryBuild() + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Network.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Network.swift new file mode 100644 index 00000000..29c2609e --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Network.swift @@ -0,0 +1,159 @@ +// +// Network.swift +// Passepartout +// +// Created by Davide De Rosa on 2/15/22. +// Copyright (c) 2024 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 . +// + +import Foundation +import PassepartoutKit + +enum Network { +} + +extension Network { + enum Choice: String, Codable { + case automatic // OpenVPN pulls from server + + case manual + + static let defaultChoice: Choice = .automatic + } +} + +protocol NetworkChoiceRepresentable { + var choice: Network.Choice { get set } +} + +protocol GatewaySettingsProviding { + var isDefaultIPv4: Bool { get } + + var isDefaultIPv6: Bool { get } +} + +protocol DNSSettingsProviding { + var dnsProtocol: DNSProtocol? { get } + + var dnsServers: [String]? { get } + + var dnsDomain: String? { get } + + var dnsSearchDomains: [String]? { get } + + var dnsHTTPSURL: URL? { get } + + var dnsTLSServerName: String? { get } +} + +protocol ProxySettingsProviding { + var proxyServer: Endpoint? { get } + + var proxyBypassDomains: [String]? { get } + + var proxyAutoConfigurationURL: URL? { get } +} + +protocol MTUSettingsProviding { + var mtuBytes: Int { get } +} + +// + +extension Network { + struct GatewaySettings: Codable, Equatable, NetworkChoiceRepresentable, GatewaySettingsProviding { + var choice: Network.Choice + + var isDefaultIPv4 = true + + var isDefaultIPv6 = true + } +} + +extension Network { + struct DNSSettings: Codable, Equatable, NetworkChoiceRepresentable, DNSSettingsProviding { + enum ConfigurationType: String, Codable { + case plain + + case https + + case tls + + case disabled + } + + var choice: Network.Choice + + var configurationType: ConfigurationType = .plain + + var dnsProtocol: DNSProtocol? { + DNSProtocol(rawValue: configurationType.rawValue) + } + + var dnsServers: [String]? + + var dnsDomain: String? + + var dnsSearchDomains: [String]? + + var dnsHTTPSURL: URL? + + var dnsTLSServerName: String? + } +} + +extension Network { + struct ProxySettings: Codable, Equatable, NetworkChoiceRepresentable, ProxySettingsProviding { + enum ConfigurationType: String, Codable { + case manual + + case pac + + case disabled + } + + var choice: Network.Choice + + var configurationType: ConfigurationType = .manual + + var proxyAddress: String? + + var proxyPort: UInt16? + + var proxyBypassDomains: [String]? + + var proxyAutoConfigurationURL: URL? + + var proxyServer: Endpoint? { + guard let address = proxyAddress, let port = proxyPort, !address.isEmpty, port > 0 else { + return nil + } + return try? Endpoint(address, port) + } + } +} + +extension Network { + struct MTUSettings: Codable, Equatable, NetworkChoiceRepresentable, MTUSettingsProviding { + var choice: Network.Choice + + var mtuBytes = 0 + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Account.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Account.swift new file mode 100644 index 00000000..0d6238e6 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Account.swift @@ -0,0 +1,58 @@ +// +// Profile+Account.swift +// Passepartout +// +// Created by Davide De Rosa on 4/6/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +extension ProfileV2 { + struct Account: Codable, Equatable { + enum AuthenticationMethod: String, Codable { + case persistent + + case interactive + + case totp + } + + var authenticationMethod: AuthenticationMethod? + + var username: String + + var password: String + + var isEmpty: Bool { + username.isEmpty && password.isEmpty + } + + init() { + username = "" + password = "" + } + + init(_ username: String, _ password: String) { + self.username = username + self.password = password + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Header.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Header.swift new file mode 100644 index 00000000..3d66c2d7 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Header.swift @@ -0,0 +1,70 @@ +// +// Profile+Header.swift +// Passepartout +// +// Created by Davide De Rosa on 2/17/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +extension ProfileV2 { + struct Header: Codable, Identifiable, Hashable { + let uuid: UUID + + var name: String + + let providerName: ProviderName? + + let lastUpdate: Date? + + init( + uuid: UUID = UUID(), + name: String = "", + providerName: ProviderName? = nil, + lastUpdate: Date? = nil + ) { + self.uuid = uuid + self.name = name + self.providerName = providerName + self.lastUpdate = lastUpdate ?? Date() + } + + // MARK: Hashable + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.uuid == rhs.uuid && + lhs.name == rhs.name && + lhs.providerName == rhs.providerName + } + + func hash(into hasher: inout Hasher) { + hasher.combine(uuid) + hasher.combine(name) + hasher.combine(providerName) + } + + // MARK: Identifiable + + var id: UUID { + uuid + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Host.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Host.swift new file mode 100644 index 00000000..b6cd7513 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Host.swift @@ -0,0 +1,34 @@ +// +// Profile+Host.swift +// Passepartout +// +// Created by Davide De Rosa on 3/10/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +extension ProfileV2 { + struct Host: Codable, Equatable { + var ovpnSettings: OpenVPNSettings? + + var wgSettings: WireGuardSettings? + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+NetworkSettings.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+NetworkSettings.swift new file mode 100644 index 00000000..833f0d66 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+NetworkSettings.swift @@ -0,0 +1,53 @@ +// +// Profile+NetworkSettings.swift +// Passepartout +// +// Created by Davide De Rosa on 2/15/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +extension ProfileV2 { + struct NetworkSettings: Codable, Equatable { + var gateway: Network.GatewaySettings + + var dns: Network.DNSSettings + + var proxy: Network.ProxySettings + + var mtu: Network.MTUSettings + + var resolvesHostname = true + + var keepsAliveOnSleep = true + + init(choice: Network.Choice) { + gateway = Network.GatewaySettings(choice: choice) + dns = Network.DNSSettings(choice: choice) + proxy = Network.ProxySettings(choice: choice) + mtu = Network.MTUSettings(choice: choice) + } + + init() { + self.init(choice: .defaultChoice) + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OnDemand.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OnDemand.swift new file mode 100644 index 00000000..9cc609b4 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OnDemand.swift @@ -0,0 +1,55 @@ +// +// Profile+OnDemand.swift +// Passepartout +// +// Created by Davide De Rosa on 2/17/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +extension ProfileV2 { + struct OnDemand: Codable, Equatable { + enum Policy: String, Codable { + case any + + case including + + case excluding // "trusted networks" + } + + enum OtherNetwork: String, Codable { + case mobile + + case ethernet + } + + var isEnabled = true + + var policy: Policy = .excluding + + var withSSIDs: [String: Bool] = [:] + + var withOtherNetworks: Set = [] + + init() { + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OpenVPNSettings.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OpenVPNSettings.swift new file mode 100644 index 00000000..910c9415 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+OpenVPNSettings.swift @@ -0,0 +1,61 @@ +// +// Profile+OpenVPNSettings.swift +// Passepartout +// +// Created by Davide De Rosa on 2/16/22. +// Copyright (c) 2024 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 . +// + +import Foundation +import PassepartoutKit + +extension ProfileV2 { + struct OpenVPNSettings: Codable, Equatable, VPNProtocolProviding { + var vpnProtocol: VPNProtocolType { + .openVPN + } + + var configuration: OpenVPN.Configuration + + var account: Account? + + var customEndpoint: Endpoint? + + init(configuration: OpenVPN.Configuration) { + 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) + } + + init(_ header: Header, configuration: OpenVPN.Configuration) { + self.header = header + currentVPNProtocol = .openVPN + host = Host() + host?.ovpnSettings = OpenVPNSettings(configuration: configuration) + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Provider.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Provider.swift new file mode 100644 index 00000000..e4268bd1 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+Provider.swift @@ -0,0 +1,58 @@ +// +// Profile+Provider.swift +// Passepartout +// +// Created by Davide De Rosa on 2/15/22. +// Copyright (c) 2024 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 . +// + +import Foundation +import PassepartoutKit + +typealias ProviderName = String + +extension ProfileV2 { + struct Provider: Codable, Equatable { + struct Settings: Codable, Equatable { + var account: Account? + + var serverId: String? + + var presetId: String? + + var favoriteLocationIds: Set? + + var customEndpoint: Endpoint? + + init() { + } + } + + let name: ProviderName + + var vpnSettings: [VPNProtocolType: Settings] = [:] + + var randomizesServer: Bool? + + init(_ name: ProviderName) { + self.name = name + } + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile+WireGuardSettings.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+WireGuardSettings.swift new file mode 100644 index 00000000..04d7b0f0 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile+WireGuardSettings.swift @@ -0,0 +1,79 @@ +// +// Profile+WireGuardSettings.swift +// Passepartout +// +// Created by Davide De Rosa on 2/17/22. +// Copyright (c) 2024 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 . +// + +import Foundation +import PassepartoutWireGuard +import PassepartoutWireGuardGo + +extension ProfileV2 { + struct WireGuardSettings: Codable, Equatable, VPNProtocolProviding { + struct WrappedConfiguration: Codable, Equatable { + let configuration: WireGuard.Configuration + + init(configuration: WireGuard.Configuration) { + self.configuration = configuration + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let wg = try container.decode(String.self) + configuration = try StandardWireGuardParser().configuration(from: wg) + } + + public func encode(to encoder: Encoder) throws { + let wg = try StandardWireGuardParser().string(from: configuration) + var container = encoder.singleValueContainer() + try container.encode(wg) + } + } + + var vpnProtocol: VPNProtocolType { + .wireGuard + } + + var configuration: WrappedConfiguration + + init(configuration: WireGuard.Configuration) { + self.configuration = WrappedConfiguration(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(_ header: Header, configuration: WireGuard.Configuration) { + self.header = header + currentVPNProtocol = .wireGuard + host = Host() + host?.wgSettings = WireGuardSettings(configuration: configuration) + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/Domain/Profile.swift b/Passepartout/Library/Sources/LegacyV2/Domain/Profile.swift new file mode 100644 index 00000000..56ada650 --- /dev/null +++ b/Passepartout/Library/Sources/LegacyV2/Domain/Profile.swift @@ -0,0 +1,118 @@ +// +// Profile.swift +// Passepartout +// +// Created by Davide De Rosa on 2/11/22. +// Copyright (c) 2024 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 . +// + +import Foundation + +enum VPNProtocolType: String, RawRepresentable, Codable { + case openVPN = "ovpn" + + case wireGuard = "wg" +} + +protocol VPNProtocolProviding { + var vpnProtocol: VPNProtocolType { get } +} + +protocol ProfileSubtype { + var vpnProtocols: [VPNProtocolType] { get } +} + +struct ProfileV2: Identifiable, Codable, Equatable { + var header: Header + + var currentVPNProtocol: VPNProtocolType + + var networkSettings = NetworkSettings() + + var onDemand = OnDemand() + + var connectionExpirationDate: Date? + + var host: Host? + + var provider: Provider? + + init(_ header: Header) { + self.header = header + currentVPNProtocol = .openVPN + } + + init(_ id: UUID = UUID(), name: String) { + header = Header( + uuid: id, + name: name, + providerName: nil + ) + currentVPNProtocol = .openVPN + } + + init(_ id: UUID = UUID(), name: String, provider: Provider) { + let header = Header( + uuid: id, + name: name, + providerName: provider.name + ) + self.init(header, provider: provider) + } + + init(_ header: Header, provider: Provider) { + guard let firstVPNProtocol = provider.vpnSettings.keys.first else { + fatalError("No VPN protocols defined in provider") + } + self.header = header + currentVPNProtocol = firstVPNProtocol + self.provider = provider + } + + // MARK: Identifiable + + var id: UUID { + header.id + } +} + +extension ProfileV2 { + static let placeholder = ProfileV2( + UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "" + ) + + static func isPlaceholder(_ id: UUID) -> Bool { + id == placeholder.id + } + + var isPlaceholder: Bool { + header.id == Self.placeholder.id + } +} + +extension ProfileV2 { + var isExpired: Bool { + guard let connectionExpirationDate else { + return false + } + return Date().distance(to: connectionExpirationDate) <= .zero + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift index e0dce423..00d42423 100644 --- a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CommonLibrary import CommonUtils import Foundation import PassepartoutKit @@ -30,16 +31,18 @@ import PassepartoutKit public final class LegacyV2 { private let profilesRepository: CDProfileRepositoryV2 - private let cloudKitIdentifier: String + private let cloudKitIdentifier: String? public init( + coreDataLogger: CoreDataPersistentStoreLogger?, profilesContainerName: String, - cloudKitIdentifier: String, - coreDataLogger: CoreDataPersistentStoreLogger + baseURL: URL? = nil, + cloudKitIdentifier: String? ) { let store = CoreDataPersistentStore( logger: coreDataLogger, containerName: profilesContainerName, + baseURL: baseURL, model: CDProfileRepositoryV2.model, cloudKitIdentifier: cloudKitIdentifier, author: nil @@ -47,8 +50,42 @@ public final class LegacyV2 { profilesRepository = CDProfileRepositoryV2(context: store.context) self.cloudKitIdentifier = cloudKitIdentifier } +} - public func fetchProfiles() async throws -> [Profile] { - try await profilesRepository.migratedProfiles() +// MARK: - Mapping + +extension LegacyV2 { + public func fetchMigratableProfiles() async throws -> [MigratableProfile] { + try await profilesRepository.migratableProfiles() + } + + public func fetchProfiles(selection: Set) async throws -> (migrated: [Profile], failed: Set) { + let profilesV2 = try await profilesRepository.profiles() + + var migrated: [Profile] = [] + var failed: Set = [] + let mapper = MapperV2() + + profilesV2.forEach { + guard selection.contains($0.id) else { + return + } + do { + let mapped = try mapper.toProfileV3($0) + migrated.append(mapped) + } catch { + pp_log(.App.migration, .error, "Unable to migrate profile \($0.id): \(error)") + failed.insert($0.id) + } + } + return (migrated, failed) + } +} + +// MARK: - Legacy profiles + +extension LegacyV2 { + func fetchProfilesV2() async throws -> [ProfileV2] { + try await profilesRepository.profiles() } } diff --git a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents index 347ee32b..d386b6f1 100644 --- a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents +++ b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents @@ -1,5 +1,5 @@ - + diff --git a/Passepartout/Library/Tests/LegacyV2Tests/LegacyV2Tests.swift b/Passepartout/Library/Tests/LegacyV2Tests/LegacyV2Tests.swift new file mode 100644 index 00000000..91cede42 --- /dev/null +++ b/Passepartout/Library/Tests/LegacyV2Tests/LegacyV2Tests.swift @@ -0,0 +1,232 @@ +// +// LegacyV2Tests.swift +// Passepartout +// +// Created by Davide De Rosa on 11/12/24. +// Copyright (c) 2024 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 . +// + +import CommonUtils +import Foundation +@testable import LegacyV2 +import PassepartoutKit +import XCTest + +final class LegacyV2Tests: XCTestCase { + func test_givenStore_whenFetchV2_thenReturnsProfilesV2() async throws { + let sut = newStore() + + let profilesV2 = try await sut.fetchProfilesV2() + XCTAssertEqual(profilesV2.count, 6) + XCTAssertEqual(Set(profilesV2.map(\.header.name)), [ + "Hide.me", + "ProtonVPN", + "TorGuard", + "vps-ta-cert-cbc256-lzo", + "vps-wg", + "Windscribe" + ]) + } + + func test_givenStore_whenFetch_thenReturnsMigratableProfiles() async throws { + let sut = newStore() + + let migratable = try await sut.fetchMigratableProfiles() + let expectedIDs = [ + "069F76BD-1F6B-425C-AD83-62477A8B6558", + "239AD322-7440-4198-990A-D91379916FE2", + "38208B87-0545-4B11-A762-D04ED7CB904F", + "5D108793-7F62-4B4C-B194-0A7204C02E99", + "8A568345-85C4-44C1-A9C4-612E8B07ADC5", + "981E7CBD-7733-4CF3-9A51-2777614ED5D4" + ] + let expectedNames = [ + "Hide.me", + "ProtonVPN", + "TorGuard", + "vps-ta-cert-cbc256-lzo", + "vps-wg", + "Windscribe" + ] + + XCTAssertEqual(migratable.count, 6) + XCTAssertEqual(Set(migratable.map(\.id)), Set(expectedIDs.compactMap(UUID.init(uuidString:)))) + XCTAssertEqual(Set(migratable.map(\.name)), Set(expectedNames)) + } + + func test_givenStore_whenMigrateHideMe_thenIsExpected() async throws { + let sut = newStore() + + let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5")) + let result = try await sut.fetchProfiles(selection: [id]) + let migrated = result.migrated + XCTAssertEqual(migrated.count, 1) + XCTAssertTrue(result.failed.isEmpty) + + let profile = try XCTUnwrap(migrated.first) + XCTAssertEqual(profile.id, id) + XCTAssertEqual(profile.name, "Hide.me") + XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 673117681.24825)) + + XCTAssertEqual(profile.modules.count, 3) + + let onDemand = try XCTUnwrap(profile.firstModule(ofType: OnDemandModule.self)) + XCTAssertTrue(onDemand.isEnabled) + XCTAssertEqual(onDemand.policy, .excluding) + XCTAssertEqual(onDemand.withSSIDs, [ + "Safe Wi-Fi": true, + "Friend's House": false + ]) + XCTAssertTrue(onDemand.withOtherNetworks.isEmpty) + + let openVPN = try XCTUnwrap(profile.firstModule(ofType: OpenVPNModule.self)) + XCTAssertEqual(openVPN.credentials?.username, "foo") + XCTAssertEqual(openVPN.credentials?.password, "bar") + + let dns = try XCTUnwrap(profile.firstModule(ofType: DNSModule.self)) + let dohURL = try XCTUnwrap(URL(string: "https://1.1.1.1/dns-query")) + XCTAssertEqual(dns.protocolType, .https(url: dohURL)) + XCTAssertEqual(dns.servers, [ + Address(rawValue: "1.1.1.1"), + Address(rawValue: "1.0.0.1") + ]) + } + + func test_givenStore_whenMigrateProtonVPN_thenIsExpected() async throws { + let sut = newStore() + + let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4")) + let result = try await sut.fetchProfiles(selection: [id]) + let migrated = result.migrated + XCTAssertEqual(migrated.count, 1) + XCTAssertTrue(result.failed.isEmpty) + + XCTAssertEqual(migrated.count, 1) + let profile = try XCTUnwrap(migrated.first) + XCTAssertEqual(profile.id, id) + XCTAssertEqual(profile.name, "ProtonVPN") + XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 724509584.854822)) + + XCTAssertEqual(profile.modules.count, 2) + + let onDemand = try XCTUnwrap(profile.firstModule(ofType: OnDemandModule.self)) + XCTAssertTrue(onDemand.isEnabled) + XCTAssertEqual(onDemand.policy, .excluding) + XCTAssertTrue(onDemand.withSSIDs.isEmpty) + XCTAssertTrue(onDemand.withOtherNetworks.isEmpty) + + let openVPN = try XCTUnwrap(profile.firstModule(ofType: OpenVPNModule.self)) + XCTAssertEqual(openVPN.credentials?.username, "foo") + XCTAssertEqual(openVPN.credentials?.password, "bar") + } + + func test_givenStore_whenMigrateVPSOpenVPN_thenIsExpected() async throws { + let sut = newStore() + + let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2")) + let result = try await sut.fetchProfiles(selection: [id]) + let migrated = result.migrated + XCTAssertEqual(migrated.count, 1) + XCTAssertTrue(result.failed.isEmpty) + + XCTAssertEqual(migrated.count, 1) + let profile = try XCTUnwrap(migrated.first) + XCTAssertEqual(profile.id, id) + XCTAssertEqual(profile.name, "vps-ta-cert-cbc256-lzo") + XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 726164772.28976)) + + XCTAssertEqual(profile.modules.count, 2) + + let onDemand = try XCTUnwrap(profile.firstModule(ofType: OnDemandModule.self)) + XCTAssertTrue(onDemand.isEnabled) + XCTAssertEqual(onDemand.policy, .excluding) + XCTAssertTrue(onDemand.withSSIDs.isEmpty) + XCTAssertTrue(onDemand.withOtherNetworks.isEmpty) + + let openVPN = try XCTUnwrap(profile.firstModule(ofType: OpenVPNModule.self)) + XCTAssertNil(openVPN.credentials) + let cfg = try XCTUnwrap(openVPN.configuration) + XCTAssertEqual(cfg.remotes, [ + try .init("1.2.3.4", .init(.udp, 1198)) + ]) + XCTAssertEqual(cfg.authUserPass, false) + XCTAssertEqual(cfg.cipher, .aes256cbc) + XCTAssertEqual(cfg.digest, .sha256) + XCTAssertEqual(cfg.keepAliveInterval, 25.0) + XCTAssertEqual(cfg.checksEKU, true) + XCTAssertEqual(cfg.tlsWrap?.strategy, .auth) + } + + func test_givenStore_whenMigrateVPSWireGuard_thenIsExpected() async throws { + let sut = newStore() + + let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558")) + let result = try await sut.fetchProfiles(selection: [id]) + let migrated = result.migrated + XCTAssertEqual(migrated.count, 1) + XCTAssertTrue(result.failed.isEmpty) + + XCTAssertEqual(migrated.count, 1) + let profile = try XCTUnwrap(migrated.first) + XCTAssertEqual(profile.id, id) + XCTAssertEqual(profile.name, "vps-wg") + XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 727398252.46203)) + + XCTAssertEqual(profile.modules.count, 2) + + let onDemand = try XCTUnwrap(profile.firstModule(ofType: OnDemandModule.self)) + XCTAssertFalse(onDemand.isEnabled) + XCTAssertEqual(onDemand.policy, .including) + XCTAssertTrue(onDemand.withSSIDs.isEmpty) + XCTAssertTrue(onDemand.withOtherNetworks.isEmpty) + + let wireGuard = try XCTUnwrap(profile.firstModule(ofType: WireGuardModule.self)) + let cfg = try XCTUnwrap(wireGuard.configuration) + XCTAssertEqual(cfg.interface.privateKey.rawValue, "6L8Cv9zpG8RTDDwvZMhv6OR3kGdd+yATuKnMQWVLT1Q=") + XCTAssertEqual(cfg.interface.addresses, [ + try .init("4.5.6.7", 32) + ]) + XCTAssertEqual(cfg.interface.dns?.servers, [ + try XCTUnwrap(Address(rawValue: "1.1.1.1")) + ]) + XCTAssertNil(cfg.interface.mtu) + XCTAssertEqual(cfg.peers.count, 1) + let peer = try XCTUnwrap(cfg.peers.first) + XCTAssertEqual(peer.publicKey.rawValue, "JZc2trzk1WZTOUTjag1lcUZ2ePpFQYSpU2d0wqAw6mU=") + XCTAssertEqual(peer.endpoint?.rawValue, "8.8.8.8:55555") + XCTAssertEqual(peer.allowedIPs, [ + try .init("0.0.0.0", 0) + ]) + } +} + +private extension LegacyV2Tests { + func newStore() -> LegacyV2 { + guard let baseURL = Bundle.module.url(forResource: "Resources", withExtension: nil) else { + fatalError() + } + return LegacyV2( + coreDataLogger: nil, + profilesContainerName: "Profiles", + baseURL: baseURL, + cloudKitIdentifier: nil + ) + } +} diff --git a/Passepartout/Library/Tests/LegacyV2Tests/MapperV2Tests.swift b/Passepartout/Library/Tests/LegacyV2Tests/MapperV2Tests.swift new file mode 100644 index 00000000..c0ae1e51 --- /dev/null +++ b/Passepartout/Library/Tests/LegacyV2Tests/MapperV2Tests.swift @@ -0,0 +1,60 @@ +// +// MapperV2Tests.swift +// Passepartout +// +// Created by Davide De Rosa on 11/12/24. +// Copyright (c) 2024 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 . +// + +import Foundation +@testable import LegacyV2 +import PassepartoutKit +import XCTest + +final class MapperV2Tests: XCTestCase { + func test_givenMapper_whenDefaultGateway_thenIncludesDefaultRoute() throws { + let sut = MapperV2() + var settings = Network.GatewaySettings(choice: .manual) + var module: IPModule + + settings.isDefaultIPv4 = true + module = try sut.toIPModule(settings, v2MTU: nil) + XCTAssertTrue(module.ipv4?.includesDefaultRoute ?? false) + + settings.isDefaultIPv6 = true + module = try sut.toIPModule(settings, v2MTU: nil) + XCTAssertTrue(module.ipv6?.includesDefaultRoute ?? false) + } + + func test_givenMapper_whenNotDefaultGateway_thenExcludesDefaultRoute() throws { + let sut = MapperV2() + var settings = Network.GatewaySettings(choice: .manual) + var module: IPModule + let defaultRoute = Route(defaultWithGateway: nil) + + settings.isDefaultIPv4 = false + module = try sut.toIPModule(settings, v2MTU: nil) + XCTAssertTrue(module.ipv4?.excludedRoutes.contains(defaultRoute) ?? false) + + settings.isDefaultIPv6 = false + module = try sut.toIPModule(settings, v2MTU: nil) + XCTAssertTrue(module.ipv6?.excludedRoutes.contains(defaultRoute) ?? false) + } +} diff --git a/Passepartout/Library/Tests/LegacyV2Tests/Resources/Profiles.sqlite b/Passepartout/Library/Tests/LegacyV2Tests/Resources/Profiles.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..cf6bd6373731696b4bdb04678343a31916e52448 GIT binary patch literal 311296 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�gO*M>PL@LL?K3^y_CtQcW@YGgDcla&_9lUcc$ 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 YbZn&A-*?GuVdkPYUGMhJiMjm$0Q|yt4*&oF literal 0 HcmV?d00001 diff --git a/Passepartout/Passepartout.xctestplan b/Passepartout/Passepartout.xctestplan index f59bfe2b..b4c3bdc6 100644 --- a/Passepartout/Passepartout.xctestplan +++ b/Passepartout/Passepartout.xctestplan @@ -32,6 +32,13 @@ "identifier" : "CommonLibraryTests", "name" : "CommonLibraryTests" } + }, + { + "target" : { + "containerPath" : "container:Passepartout\/Library", + "identifier" : "LegacyV2Tests", + "name" : "LegacyV2Tests" + } } ], "version" : 1 diff --git a/fastlane/.env b/fastlane/.env index 562f8922..24fd4515 100644 --- a/fastlane/.env +++ b/fastlane/.env @@ -2,6 +2,7 @@ LC_ALL="en_US.UTF-8" LANG="en_US.UTF-8" GYM_SCHEME="Passepartout" +SCAN_SCHEME="Passepartout" FL_VERSION_NUMBER_TARGET="Passepartout" FL_BUILD_NUMBER_PROJECT="Passepartout.xcodeproj" PILOT_BETA_APP_DESCRIPTION="Passepartout is your go-to app for VPN and privacy." diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 647ccd36..9e65c859 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -61,6 +61,15 @@ lane :bump do |options| ) end +desc "Run app tests" +lane :test do + scan( + clean: true, + xcargs: "CODE_SIGNING_ALLOWED=NO", + verbose: true + ) +end + desc "Push a new beta build to TestFlight" lane :beta do preface = ENV["TESTFLIGHT_PREFACE"]