Exclude OpenVPN endpoints (#987)

Exclude endpoints from OpenVPN modules and providers with the generic
Blacklist<T> observable. Eventually, rebuild the Profile in
PacketTunnelProvider (via DefaultTunnelProcessor) with the applied
exclusions from preferences.

Revisit approach to preferences:

- Module preferences
  - Tied to the module and therefore to the parent profile
- Load/save in ProfileEditor on request (rather than on
ProfileEditor.load)
- Provider preferences
  - Shared globally across profiles
  - Load/save in module view if needed

For more consistency with Core Data:

- Revert to observables for both module and provider preferences
- Treat excluded endpoints as relationships rather than a serialized
Array
- Add/remove single relationships over bulk delete + re-add
- Do not map the relationships, Blacklist only needs exists/add/remove:
  - isExcludedEndpoint
  - addExcludedEndpoint
  - removeExcludedEndpoint

Some clean-up:

- Move the importer logic to OpenVPNView.ImportModifier
- Move the preview data to OpenVPN.Configuration.Builder.forPreviews
- Drop objectWillChange.send() on .repository didSet to avoid potential
recursion during SwiftUI updates

Closes #971
This commit is contained in:
Davide 2024-12-09 02:00:55 +01:00 committed by GitHub
parent 5f20d791c2
commit fae0200995
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 724 additions and 300 deletions

View File

@ -1,8 +1,8 @@
//
// UUID+RawRepresentable.swift
// CDExcludedEndpoint.swift
// Passepartout
//
// Created by Davide De Rosa on 12/4/24.
// Created by Davide De Rosa on 12/8/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
@ -23,14 +23,16 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
extension UUID: @retroactive RawRepresentable {
public init?(rawValue: String) {
self.init(uuidString: rawValue)
@objc(CDExcludedEndpoint)
final class CDExcludedEndpoint: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDExcludedEndpoint> {
NSFetchRequest<CDExcludedEndpoint>(entityName: "CDExcludedEndpoint")
}
public var rawValue: String {
uuidString
}
@NSManaged var endpoint: String?
@NSManaged var modulePreferences: CDModulePreferencesV3?
@NSManaged var providerPreferences: CDProviderPreferencesV3?
}

View File

@ -33,4 +33,5 @@ final class CDModulePreferencesV3: NSManagedObject {
}
@NSManaged var uuid: UUID?
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
}

View File

@ -34,4 +34,5 @@ final class CDProviderPreferencesV3: NSManagedObject {
@NSManaged var providerId: String?
@NSManaged var favoriteServerIds: Data?
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
}

View File

@ -29,12 +29,27 @@ import Foundation
import PassepartoutKit
struct DomainMapper {
func preferences(from entity: CDModulePreferencesV3) throws -> ModulePreferences {
ModulePreferences()
func excludedEndpoints(from entities: Set<CDExcludedEndpoint>?) -> Set<ExtendedEndpoint> {
entities.map {
Set($0.compactMap {
$0.endpoint.map {
ExtendedEndpoint(rawValue: $0)
} ?? nil
})
} ?? []
}
}
struct CoreDataMapper {
func set(_ entity: CDModulePreferencesV3, from preferences: ModulePreferences) throws {
let context: NSManagedObjectContext
func cdExcludedEndpoint(from endpoint: ExtendedEndpoint) -> CDExcludedEndpoint {
let cdEndpoint = CDExcludedEndpoint(context: context)
cdEndpoint.endpoint = endpoint.rawValue
return cdEndpoint
}
func cdExcludedEndpoints(from endpoints: Set<ExtendedEndpoint>) -> Set<CDExcludedEndpoint> {
Set(endpoints.map(cdExcludedEndpoint(from:)))
}
}

View File

@ -1,10 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDExcludedEndpoint" representedClassName="CDExcludedEndpoint" syncable="YES">
<attribute name="endpoint" optional="YES" attributeType="String"/>
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
<relationship name="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDProviderPreferencesV3"/>
</entity>
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="modulePreferences" inverseEntity="CDExcludedEndpoint"/>
</entity>
<entity name="CDProviderPreferencesV3" representedClassName="CDProviderPreferencesV3" syncable="YES">
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="providerPreferences" inverseEntity="CDExcludedEndpoint"/>
</entity>
</model>

View File

@ -30,74 +30,71 @@ import Foundation
import PassepartoutKit
extension AppData {
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository {
CDModulePreferencesRepositoryV3(context: context)
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository {
try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId)
}
}
private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository {
private nonisolated let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
private let entity: CDModulePreferencesV3
init(context: NSManagedObjectContext, moduleId: UUID) throws {
self.context = context
}
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
try context.performAndWait {
entity = try context.performAndWait {
let request = CDModulePreferencesV3.fetchRequest()
request.predicate = NSPredicate(format: "any uuid in %@", moduleIds.map(\.uuidString))
let entities = try request.execute()
let mapper = DomainMapper()
return entities.reduce(into: [:]) {
guard let moduleId = $1.uuid else {
return
}
do {
let preferences = try mapper.preferences(from: $1)
$0[moduleId] = preferences
} catch {
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
}
}
}
}
func set(_ preferences: [UUID: ModulePreferences]) throws {
try context.performAndWait {
let request = CDModulePreferencesV3.fetchRequest()
request.predicate = NSPredicate(format: "any uuid in %@", Array(preferences.keys))
var entities = try request.execute()
let existingIds = entities.compactMap(\.uuid)
let newIds = Set(preferences.keys).subtracting(existingIds)
newIds.forEach {
let newEntity = CDModulePreferencesV3(context: context)
newEntity.uuid = $0
entities.append(newEntity)
}
let mapper = CoreDataMapper()
try entities.forEach {
guard let id = $0.uuid, let entityPreferences = preferences[id] else {
return
}
try mapper.set($0, from: entityPreferences)
}
guard context.hasChanges else {
return
}
request.predicate = NSPredicate(format: "uuid == %@", moduleId.uuidString)
do {
try context.save()
let entity = try request.execute().first ?? CDModulePreferencesV3(context: context)
entity.uuid = moduleId
return entity
} catch {
context.rollback()
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
throw error
}
}
}
func rollback() {
context.rollback()
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
context.performAndWait {
entity.excludedEndpoints?.contains {
$0.endpoint == endpoint.rawValue
} ?? false
}
}
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
context.performAndWait {
let mapper = CoreDataMapper(context: context)
let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint)
cdEndpoint.modulePreferences = entity
entity.excludedEndpoints?.insert(cdEndpoint)
}
}
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
context.performAndWait {
guard let found = entity.excludedEndpoints?.first(where: {
$0.endpoint == endpoint.rawValue
}) else {
return
}
entity.excludedEndpoints?.remove(found)
context.delete(found)
}
}
func save() throws {
guard context.hasChanges else {
return
}
do {
try context.save()
} catch {
context.rollback()
throw error
}
}
}

View File

@ -82,6 +82,35 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
}
}
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
context.performAndWait {
entity.excludedEndpoints?.contains {
$0.endpoint == endpoint.rawValue
} ?? false
}
}
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
context.performAndWait {
let mapper = CoreDataMapper(context: context)
let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint)
cdEndpoint.providerPreferences = entity
entity.excludedEndpoints?.insert(cdEndpoint)
}
}
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
context.performAndWait {
guard let found = entity.excludedEndpoints?.first(where: {
$0.endpoint == endpoint.rawValue
}) else {
return
}
entity.excludedEndpoints?.remove(found)
context.delete(found)
}
}
func save() throws {
guard context.hasChanges else {
return

View File

@ -229,11 +229,7 @@ extension AppCoordinator {
extension AppCoordinator {
public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) {
pp_log(.app, .info, "Present interactive login")
interactiveManager.present(
with: profile,
preferencesManager: preferencesManager,
onComplete: onComplete
)
interactiveManager.present(with: profile, onComplete: onComplete)
}
public func onProviderEntityRequired(_ profile: Profile, force: Bool) {
@ -302,7 +298,7 @@ private extension AppCoordinator {
func editProfile(_ profile: EditableProfile, initialModuleId: UUID?) {
profilePath = NavigationPath()
let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)
profileEditor.load(profile, isShared: isShared, preferencesManager: preferencesManager)
profileEditor.load(profile, isShared: isShared)
present(.editProfile(initialModuleId))
}
}

View File

@ -23,6 +23,8 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
@ -34,9 +36,12 @@ extension OpenVPNView {
let credentialsRoute: (any Hashable)?
@ObservedObject
var allowedEndpoints: Blacklist<ExtendedEndpoint>
var body: some View {
moduleSection(for: accountRows, header: Strings.Global.Nouns.account)
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
remotesSection
if !isServerPushed {
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
}
@ -61,6 +66,68 @@ extension OpenVPNView {
}
}
// MARK: - Editable
private extension OpenVPNView.ConfigurationView {
var remotesSection: some View {
configuration.remotes.map { remotes in
ForEach(remotes, id: \.rawValue) { remote in
SelectableRemoteButton(
remote: remote,
all: Set(remotes),
allowedEndpoints: allowedEndpoints
)
}
.themeSection(header: Strings.Modules.Openvpn.remotes)
}
}
}
private struct SelectableRemoteButton: View {
let remote: ExtendedEndpoint
let all: Set<ExtendedEndpoint>
@ObservedObject
var allowedEndpoints: Blacklist<ExtendedEndpoint>
var body: some View {
Button {
if allowedEndpoints.isAllowed(remote) {
if remaining.count > 1 {
allowedEndpoints.deny(remote)
}
} else {
allowedEndpoints.allow(remote)
}
} label: {
HStack {
VStack(alignment: .leading) {
Text(remote.address.rawValue)
.font(.headline)
Text("\(remote.proto.socketType.rawValue):\(remote.proto.port.description)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
ThemeImage(.marked)
.opaque(allowedEndpoints.isAllowed(remote))
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private var remaining: Set<ExtendedEndpoint> {
all.filter {
allowedEndpoints.isAllowed($0)
}
}
}
// MARK: - Constant
private extension OpenVPNView.ConfigurationView {
var accountRows: [ModuleRow]? {
guard let credentialsRoute else {
@ -75,20 +142,14 @@ private extension OpenVPNView.ConfigurationView {
]
}
var remotesRows: [ModuleRow]? {
configuration.remotes?.map {
.copiableText(
value: "\($0.address.rawValue)\($0.proto.socketType.rawValue):\($0.proto.port)"
)
}
.nilIfEmpty
}
var pullRows: [ModuleRow]? {
configuration.pullMask?.map {
.text(caption: $0.localizedDescription, value: nil)
}
.nilIfEmpty
configuration.pullMask?
.map(\.localizedDescription)
.sorted()
.map {
.text(caption: $0, value: nil)
}
.nilIfEmpty
}
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
@ -130,16 +191,15 @@ private extension OpenVPNView.ConfigurationView {
configuration.routingPolicies?
.compactMap {
switch $0 {
case .IPv4:
return .text(caption: Strings.Unlocalized.ipv4)
case .IPv6:
return .text(caption: Strings.Unlocalized.ipv6)
default:
return nil
case .IPv4: return Strings.Unlocalized.ipv4
case .IPv6: return Strings.Unlocalized.ipv6
default: return nil
}
}
.sorted()
.map {
.text(caption: $0)
}
.nilIfEmpty
}
@ -273,3 +333,33 @@ private extension OpenVPNView.ConfigurationView {
return rows.nilIfEmpty
}
}
// MARK: - Previews
#Preview {
struct Preview: View {
@StateObject
private var allowedEndpoints = Blacklist<ExtendedEndpoint> { _ in
true
} allow: { _ in
//
} deny: { _ in
//
}
var body: some View {
Form {
OpenVPNView.ConfigurationView(
isServerPushed: false,
configuration: .forPreviews,
credentialsRoute: nil,
allowedEndpoints: allowedEndpoints
)
}
.withMockEnvironment()
}
}
return Preview()
}

View File

@ -0,0 +1,89 @@
//
// OpenVPNView+Extensions.swift
// Passepartout
//
// Created by Davide De Rosa on 12/8/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 <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
// swiftlint: disable force_try
extension OpenVPN.Configuration.Builder {
static var forPreviews: Self {
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
builder.noPullMask = [.proxy]
builder.authUserPass = true
builder.remotes = [
.init(rawValue: "2.2.2.2:UDP:2222")!,
.init(rawValue: "6.6.6.6:UDP:6666")!,
.init(rawValue: "12.12.12.12:TCP:21212")!,
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
]
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
.init(.init(rawValue: "55.10.20.30/32"), nil)
])
.excluding(routes: [
.init(.init(rawValue: "88.40.30.30/32"), nil),
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
])
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
.init(.init(rawValue: "55:10:20::30/128"), nil),
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
])
.excluding(routes: [
.init(.init(rawValue: "88:40:30::30/32"), nil)
])
builder.routingPolicies = [.IPv4, .IPv6]
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
builder.dnsDomain = "domain.com"
builder.searchDomains = ["search1.com", "search2.com"]
builder.httpProxy = try! .init("10.10.10.10", 1080)
builder.httpsProxy = try! .init("10.10.10.10", 8080)
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
builder.ca = .init(mockPem: "ca-certificate")
builder.clientCertificate = .init(mockPem: "client-certificate")
builder.clientKey = .init(mockPem: "client-key")
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
builder.keepAliveInterval = 10.0
builder.renegotiatesAfter = 60.0
builder.randomizeEndpoint = true
builder.randomizeHostnames = true
return builder
}
}
// swiftlint: enable force_try
private extension OpenVPN.CryptoContainer {
init(mockPem: String) {
self.init(pem: """
-----BEGIN CERTIFICATE-----
\(mockPem)
-----END CERTIFICATE-----
""")
}
}

View File

@ -0,0 +1,122 @@
//
// OpenVPNView+Import.swift
// Passepartout
//
// Created by Davide De Rosa on 12/8/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 <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
extension OpenVPNView {
struct ImportModifier: ViewModifier {
@Binding
var draft: OpenVPNModule.Builder
let impl: OpenVPNModule.Implementation?
@Binding
var isImporting: Bool
@ObservedObject
var errorHandler: ErrorHandler
@State
private var importURL: URL?
@State
private var importPassphrase: String?
@State
private var requiresPassphrase = false
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.item],
onCompletion: importConfiguration
)
.alert(
draft.moduleType.localizedDescription,
isPresented: $requiresPassphrase,
presenting: importURL,
actions: { url in
SecureField(
Strings.Placeholders.secret,
text: $importPassphrase ?? ""
)
Button(Strings.Alerts.Import.Passphrase.ok) {
importConfiguration(from: .success(url))
}
Button(Strings.Global.Actions.cancel, role: .cancel) {
isImporting = false
}
},
message: {
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
}
)
}
}
}
private extension OpenVPNView.ImportModifier {
func importConfiguration(from result: Result<URL, Error>) {
do {
let url = try result.get()
guard url.startAccessingSecurityScopedResource() else {
throw AppError.permissionDenied
}
defer {
url.stopAccessingSecurityScopedResource()
}
importURL = url
guard let impl else {
fatalError("Requires OpenVPNModule implementation")
}
guard let parser = impl.importer as? StandardOpenVPNParser else {
fatalError("OpenVPNModule importer should be StandardOpenVPNParser")
}
let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase)
draft.configurationBuilder = parsed.configuration.builder()
} catch StandardOpenVPNParserError.encryptionPassphrase,
StandardOpenVPNParserError.unableToDecrypt {
Task {
// XXX: re-present same alert after artificial delay
try? await Task.sleep(for: .milliseconds(500))
importPassphrase = nil
requiresPassphrase = true
}
} catch {
pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)")
errorHandler.handle(
(error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error,
title: draft.moduleType.localizedDescription
)
}
}
}

View File

@ -30,6 +30,9 @@ import SwiftUI
struct OpenVPNView: View, ModuleDraftEditing {
@EnvironmentObject
private var preferencesManager: PreferencesManager
@Environment(\.navigationPath)
private var path
@ -45,18 +48,12 @@ struct OpenVPNView: View, ModuleDraftEditing {
@State
private var isImporting = false
@State
private var importURL: URL?
@State
private var importPassphrase: String?
@State
private var requiresPassphrase = false
@State
private var paywallReason: PaywallReason?
@StateObject
private var providerPreferences = ProviderPreferences()
@StateObject
private var errorHandler: ErrorHandler = .default()
@ -64,7 +61,6 @@ struct OpenVPNView: View, ModuleDraftEditing {
module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
editor = ProfileEditor(modules: [module])
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
impl = nil
isServerPushed = true
}
@ -79,11 +75,12 @@ struct OpenVPNView: View, ModuleDraftEditing {
var body: some View {
contentView
.moduleView(editor: editor, draft: draft.wrappedValue)
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.item],
onCompletion: importConfiguration
)
.modifier(ImportModifier(
draft: draft,
impl: impl,
isImporting: $isImporting,
errorHandler: errorHandler
))
.modifier(PaywallModifier(reason: $paywallReason))
.navigationDestination(for: Subroute.self, destination: destination)
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
@ -101,7 +98,8 @@ private extension OpenVPNView {
ConfigurationView(
isServerPushed: isServerPushed,
configuration: configuration,
credentialsRoute: Subroute.credentials
credentialsRoute: Subroute.credentials,
allowedEndpoints: allowedEndpoints
)
} else {
emptyConfigurationView
@ -126,31 +124,12 @@ private extension OpenVPNView {
Button(Strings.Modules.General.Rows.importFromFile.withTrailingDots) {
isImporting = true
}
.alert(
module.moduleType.localizedDescription,
isPresented: $requiresPassphrase,
presenting: importURL,
actions: { url in
SecureField(
Strings.Placeholders.secret,
text: $importPassphrase ?? ""
)
Button(Strings.Alerts.Import.Passphrase.ok) {
importConfiguration(from: .success(url))
}
Button(Strings.Global.Actions.cancel, role: .cancel) {
isImporting = false
}
},
message: {
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
}
)
}
var providerModifier: some ViewModifier {
VPNProviderContentModifier(
providerId: providerId,
providerPreferences: providerPreferences,
selectedEntity: providerEntity,
paywallReason: $paywallReason,
entityDestination: Subroute.providerServer,
@ -165,50 +144,6 @@ private extension OpenVPNView {
}
}
private extension OpenVPNView {
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
path.wrappedValue.removeLast()
}
func importConfiguration(from result: Result<URL, Error>) {
do {
let url = try result.get()
guard url.startAccessingSecurityScopedResource() else {
throw AppError.permissionDenied
}
defer {
url.stopAccessingSecurityScopedResource()
}
importURL = url
guard let impl else {
fatalError("Requires OpenVPNModule implementation")
}
guard let parser = impl.importer as? StandardOpenVPNParser else {
fatalError("OpenVPNModule importer should be StandardOpenVPNParser")
}
let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase)
draft.wrappedValue.configurationBuilder = parsed.configuration.builder()
} catch StandardOpenVPNParserError.encryptionPassphrase,
StandardOpenVPNParserError.unableToDecrypt {
Task {
// XXX: re-present same alert after artificial delay
try? await Task.sleep(for: .milliseconds(500))
importPassphrase = nil
requiresPassphrase = true
}
} catch {
pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)")
errorHandler.handle(
(error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error,
title: module.moduleType.localizedDescription
)
}
}
}
// MARK: - Destinations
private extension OpenVPNView {
@ -239,7 +174,8 @@ private extension OpenVPNView {
ConfigurationView(
isServerPushed: false,
configuration: configuration.builder(),
credentialsRoute: nil
credentialsRoute: nil,
allowedEndpoints: allowedEndpoints
)
}
.themeForm()
@ -260,66 +196,30 @@ private extension OpenVPNView {
}
}
// MARK: - Previews
// MARK: - Logic
// swiftlint: disable force_try
#Preview {
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
builder.noPullMask = [.proxy]
builder.authUserPass = true
builder.remotes = [
.init(rawValue: "2.2.2.2:UDP:2222")!,
.init(rawValue: "6.6.6.6:UDP:6666")!,
.init(rawValue: "12.12.12.12:TCP:21212")!,
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
]
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
.init(.init(rawValue: "55.10.20.30/32"), nil)
])
.excluding(routes: [
.init(.init(rawValue: "88.40.30.30/32"), nil),
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
])
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
.init(.init(rawValue: "55:10:20::30/128"), nil),
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
])
.excluding(routes: [
.init(.init(rawValue: "88:40:30::30/32"), nil)
])
builder.routingPolicies = [.IPv4, .IPv6]
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
builder.dnsDomain = "domain.com"
builder.searchDomains = ["search1.com", "search2.com"]
builder.httpProxy = try! .init("10.10.10.10", 1080)
builder.httpsProxy = try! .init("10.10.10.10", 8080)
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
builder.ca = .init(mockPem: "ca-certificate")
builder.clientCertificate = .init(mockPem: "client-certificate")
builder.clientKey = .init(mockPem: "client-key")
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
builder.keepAliveInterval = 10.0
builder.renegotiatesAfter = 60.0
builder.randomizeEndpoint = true
builder.randomizeHostnames = true
private extension OpenVPNView {
var preferences: ModulePreferences {
editor.preferences(forModuleWithId: module.id, manager: preferencesManager)
}
let module = OpenVPNModule.Builder(configurationBuilder: builder)
return module.preview(title: "OpenVPN")
}
// swiftlint: enable force_try
var allowedEndpoints: Blacklist<ExtendedEndpoint> {
if draft.wrappedValue.providerSelection != nil {
return providerPreferences.allowedEndpoints()
} else {
return preferences.allowedEndpoints()
}
}
private extension OpenVPN.CryptoContainer {
init(mockPem: String) {
self.init(pem: """
-----BEGIN CERTIFICATE-----
\(mockPem)
-----END CERTIFICATE-----
""")
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
path.wrappedValue.removeLast()
}
}
// MARK: - Previews
#Preview {
let module = OpenVPNModule.Builder(configurationBuilder: .forPreviews)
return module.preview(title: "OpenVPN")
}

View File

@ -79,6 +79,7 @@ private extension WireGuardView {
var providerModifier: some ViewModifier {
VPNProviderContentModifier(
providerId: providerId,
providerPreferences: nil,
selectedEntity: providerEntity,
paywallReason: $paywallReason,
entityDestination: Subroute.providerServer,

View File

@ -34,11 +34,16 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
@EnvironmentObject
private var providerManager: ProviderManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
let apis: [APIMapper]
@Binding
var providerId: ProviderID?
let providerPreferences: ProviderPreferences?
let entityType: Entity.Type
@Binding
@ -57,9 +62,11 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
if let newId {
await refreshInfrastructure(for: newId)
}
loadPreferences(for: newId)
onSelectProvider(providerManager, newId, false)
}
}
.onDisappear(perform: savePreferences)
.disabled(providerManager.isLoading)
content
@ -147,6 +154,7 @@ private extension ProviderContentModifier {
await refreshIndex()
if let providerId {
onSelectProvider(providerManager, providerId, true)
loadPreferences(for: providerId)
}
}
}
@ -172,6 +180,35 @@ private extension ProviderContentModifier {
return false
}
}
func loadPreferences(for providerId: ProviderID?) {
guard let providerPreferences else {
return
}
if let providerId {
do {
pp_log(.app, .debug, "Load preferences for provider \(providerId)")
providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
} catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
providerPreferences.repository = nil
}
} else {
providerPreferences.repository = nil
}
}
func savePreferences() {
guard let providerPreferences else {
return
}
do {
pp_log(.app, .debug, "Save preferences for provider \(providerId.debugDescription)")
try providerPreferences.save()
} catch {
pp_log(.app, .error, "Unable to save preferences for provider \(providerId.debugDescription): \(error)")
}
}
}
// MARK: - Preview
@ -182,6 +219,7 @@ private extension ProviderContentModifier {
.modifier(ProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
entityType: VPNEntity<OpenVPN.Configuration>.self,
paywallReason: .constant(nil),
providerRows: {},

View File

@ -34,6 +34,8 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
@Binding
var providerId: ProviderID?
let providerPreferences: ProviderPreferences?
@Binding
var selectedEntity: VPNEntity<Configuration>?
@ -51,6 +53,7 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
.modifier(ProviderContentModifier(
apis: apis,
providerId: $providerId,
providerPreferences: providerPreferences,
entityType: VPNEntity<Configuration>.self,
paywallReason: $paywallReason,
providerRows: {
@ -94,6 +97,7 @@ private extension VPNProviderContentModifier {
.modifier(VPNProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
paywallReason: .constant(nil),
entityDestination: "Destination",

View File

@ -28,65 +28,78 @@ import Foundation
import PassepartoutKit
public final class PreferencesManager: ObservableObject, Sendable {
private let modulesRepository: ModulePreferencesRepository
private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository
private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository
public init(
modulesRepository: ModulePreferencesRepository? = nil,
modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil,
providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil
) {
self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository()
self.modulesFactory = modulesFactory ?? { _ in
DummyModulePreferencesRepository()
}
self.providersFactory = providersFactory ?? { _ in
DummyProviderPreferencesRepository()
}
}
}
// MARK: - Modules
extension PreferencesManager {
public func preferences(forProfile profile: Profile) throws -> [UUID: ModulePreferences] {
try preferences(forModulesWithIds: profile.modules.map(\.id))
public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository {
try modulesFactory(moduleId)
}
public func preferences(forProfile editableProfile: EditableProfile) throws -> [UUID: ModulePreferences] {
try preferences(forModulesWithIds: editableProfile.modules.map(\.id))
}
public func savePreferences(_ preferences: [UUID: ModulePreferences]) throws {
try modulesRepository.set(preferences)
}
}
private extension PreferencesManager {
func preferences(forModulesWithIds moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
try modulesRepository.preferences(for: moduleIds)
}
}
// MARK: - Providers
extension PreferencesManager {
public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository {
try providersFactory(providerId)
}
}
@MainActor
extension PreferencesManager {
public func preferences(forModuleWithId moduleId: UUID) throws -> ModulePreferences {
let object = ModulePreferences()
object.repository = try modulesFactory(moduleId)
return object
}
public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences {
let object = ProviderPreferences()
object.repository = try providersFactory(providerId)
return object
}
}
// MARK: - Dummy
private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
[:]
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
false
}
func set(_ preferences: [UUID: ModulePreferences]) throws {
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
}
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
}
func save() throws {
}
}
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
var favoriteServers: Set<String> = []
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
false
}
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
}
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
}
func save() throws {
}
}

View File

@ -23,10 +23,28 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
public struct ModulePreferences: Sendable {
@MainActor
public final class ModulePreferences: ObservableObject {
public var repository: ModulePreferencesRepository?
public init() {
}
public func allowedEndpoints() -> Blacklist<ExtendedEndpoint> {
Blacklist { [weak self] in
self?.repository?.isExcludedEndpoint($0) != true
} allow: { [weak self] in
self?.repository?.removeExcludedEndpoint($0)
} deny: { [weak self] in
self?.repository?.addExcludedEndpoint($0)
}
}
public func save() throws {
try repository?.save()
}
}

View File

@ -23,16 +23,13 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
@MainActor
public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository {
public var repository: ProviderPreferencesRepository? {
didSet {
objectWillChange.send()
}
}
public final class ProviderPreferences: ObservableObject {
public var repository: ProviderPreferencesRepository?
public init() {
}
@ -47,6 +44,16 @@ public final class ProviderPreferences: ObservableObject, ProviderPreferencesRep
}
}
public func allowedEndpoints() -> Blacklist<ExtendedEndpoint> {
Blacklist { [weak self] in
self?.repository?.isExcludedEndpoint($0) != true
} allow: { [weak self] in
self?.repository?.removeExcludedEndpoint($0)
} deny: { [weak self] in
self?.repository?.addExcludedEndpoint($0)
}
}
public func save() throws {
try repository?.save()
}

View File

@ -26,8 +26,12 @@
import Foundation
import PassepartoutKit
public protocol ModulePreferencesRepository: Sendable {
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences]
public protocol ModulePreferencesRepository {
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
func set(_ preferences: [UUID: ModulePreferences]) throws
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func save() throws
}

View File

@ -29,5 +29,11 @@ import PassepartoutKit
public protocol ProviderPreferencesRepository {
var favoriteServers: Set<String> { get set }
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
func save() throws
}

View File

@ -0,0 +1,59 @@
//
// Blacklist.swift
// Passepartout
//
// Created by Davide De Rosa on 12/8/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 <http://www.gnu.org/licenses/>.
//
import Foundation
@MainActor
public final class Blacklist<T>: ObservableObject where T: Equatable {
private let isAllowed: (T) -> Bool
private let allow: (T) -> Void
private let deny: (T) -> Void
public init(
isAllowed: @escaping (T) -> Bool,
allow: @escaping (T) -> Void,
deny: @escaping (T) -> Void
) {
self.isAllowed = isAllowed
self.allow = allow
self.deny = deny
}
public func isAllowed(_ value: T) -> Bool {
isAllowed(value)
}
public func allow(_ value: T) {
objectWillChange.send()
allow(value)
}
public func deny(_ value: T) {
objectWillChange.send()
deny(value)
}
}

View File

@ -41,9 +41,9 @@ public final class InteractiveManager: ObservableObject {
public init() {
}
public func present(with profile: Profile, preferencesManager: PreferencesManager, onComplete: CompletionBlock?) {
public func present(with profile: Profile, onComplete: CompletionBlock?) {
editor = ProfileEditor()
editor.load(profile.editable(), isShared: false, preferencesManager: preferencesManager)
editor.load(profile.editable(), isShared: false)
self.onComplete = onComplete
isPresented = true
}

View File

@ -37,8 +37,7 @@ public final class ProfileEditor: ObservableObject {
@Published
public var isShared: Bool
@Published
public var preferences: [UUID: ModulePreferences]
private var trackedPreferences: [UUID: ModulePreferences]
private(set) var removedModules: [UUID: any ModuleBuilder]
@ -50,7 +49,7 @@ public final class ProfileEditor: ObservableObject {
public init(profile: Profile) {
editableProfile = profile.editable()
isShared = false
preferences = [:]
trackedPreferences = [:]
removedModules = [:]
}
@ -61,7 +60,7 @@ public final class ProfileEditor: ObservableObject {
activeModulesIds: Set(modules.map(\.id))
)
isShared = false
preferences = [:]
trackedPreferences = [:]
removedModules = [:]
}
}
@ -202,22 +201,24 @@ extension ProfileEditor {
// MARK: - Load/Save
extension ProfileEditor {
public func load(
_ profile: EditableProfile,
isShared: Bool,
preferencesManager: PreferencesManager
) {
public func load(_ profile: EditableProfile, isShared: Bool) {
editableProfile = profile
self.isShared = isShared
do {
preferences = try preferencesManager.preferences(forProfile: profile)
} catch {
preferences = [:]
pp_log(.App.profiles, .error, "Unable to load preferences for profile \(profile.id): \(error)")
}
removedModules = [:]
}
public func preferences(forModuleWithId moduleId: UUID, manager: PreferencesManager) -> ModulePreferences {
do {
pp_log(.App.profiles, .debug, "Track preferences for module \(moduleId)")
let observable = try trackedPreferences[moduleId] ?? manager.preferences(forModuleWithId: moduleId)
trackedPreferences[moduleId] = observable
return observable
} catch {
pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)")
return ModulePreferences()
}
}
@discardableResult
public func save(
to profileManager: ProfileManager,
@ -226,11 +227,15 @@ extension ProfileEditor {
do {
let newProfile = try build()
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared)
do {
try preferencesManager.savePreferences(preferences)
} catch {
pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)")
trackedPreferences.forEach {
do {
pp_log(.App.profiles, .debug, "Save tracked preferences for module \($0.key)")
try $0.value.save()
} catch {
pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)")
}
}
trackedPreferences.removeAll()
return newProfile
} catch {
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")

View File

@ -41,12 +41,4 @@ extension ProfileEditor {
self?.saveModule($0, activating: false)
}
}
public func binding(forPreferencesOf moduleId: UUID) -> Binding<ModulePreferences> {
Binding { [weak self] in
self?.preferences[moduleId] ?? ModulePreferences()
} set: { [weak self] in
self?.preferences[moduleId] = $0
}
}
}

View File

@ -41,7 +41,9 @@ extension Dependencies {
author: nil
)
return PreferencesManager(
modulesRepository: AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context),
modulesFactory: {
try AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context, moduleId: $0)
},
providersFactory: {
try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0)
}

View File

@ -37,6 +37,32 @@ final class DefaultTunnelProcessor: Sendable {
extension DefaultTunnelProcessor: PacketTunnelProcessor {
func willStart(_ profile: Profile) throws -> Profile {
profile
do {
var builder = profile.builder()
try builder.modules.forEach {
guard var moduleBuilder = $0.moduleBuilder() as? OpenVPNModule.Builder else {
return
}
let modulesPreferences = try preferencesManager.preferencesRepository(forModuleWithId: moduleBuilder.id)
moduleBuilder.configurationBuilder?.remotes?.removeAll {
modulesPreferences.isExcludedEndpoint($0)
}
if let providerId = moduleBuilder.providerId {
let providerPreferences = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
moduleBuilder.configurationBuilder?.remotes?.removeAll {
providerPreferences.isExcludedEndpoint($0)
}
}
let module = try moduleBuilder.tryBuild()
builder.saveModule(module)
}
return try builder.tryBuild()
} catch {
pp_log(.app, .error, "Unable to process profile, revert to original: \(error)")
return profile
}
}
}