mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2024-12-25 19:02:37 +00:00
7c27125dd7
Move the following dependencies: - OpenVPN/OpenSSL - WireGuard/Go up the chain until the main App/Tunnel targets, so that UILibrary and CommonLibrary can abstract from these unnecessary details. Instead, give module views access to generic implementations via Registry. Incidentally, this fixes an issue preventing TV previews from working due to OpenSSL linkage.
313 lines
10 KiB
Swift
313 lines
10 KiB
Swift
//
|
|
// OpenVPNView.swift
|
|
// Passepartout
|
|
//
|
|
// Created by Davide De Rosa on 2/17/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
|
|
|
|
struct OpenVPNView: View, ModuleDraftEditing {
|
|
|
|
@Environment(\.navigationPath)
|
|
private var path
|
|
|
|
@ObservedObject
|
|
var editor: ProfileEditor
|
|
|
|
let module: OpenVPNModule.Builder
|
|
|
|
let impl: OpenVPNModule.Implementation?
|
|
|
|
private let isServerPushed: Bool
|
|
|
|
@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 errorHandler: ErrorHandler = .default()
|
|
|
|
init(serverConfiguration: OpenVPN.Configuration) {
|
|
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
|
let editor = ProfileEditor(modules: [module])
|
|
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
|
|
|
|
self.editor = editor
|
|
self.module = module
|
|
impl = nil
|
|
isServerPushed = true
|
|
}
|
|
|
|
init(editor: ProfileEditor, module: OpenVPNModule.Builder, impl: OpenVPNModule.Implementation?) {
|
|
self.editor = editor
|
|
self.module = module
|
|
self.impl = impl
|
|
isServerPushed = false
|
|
}
|
|
|
|
var body: some View {
|
|
contentView
|
|
.moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed)
|
|
.fileImporter(
|
|
isPresented: $isImporting,
|
|
allowedContentTypes: [.item],
|
|
onCompletion: importConfiguration
|
|
)
|
|
.modifier(PaywallModifier(reason: $paywallReason))
|
|
.navigationDestination(for: Subroute.self, destination: destination)
|
|
.themeAnimation(on: providerId.wrappedValue, category: .modules)
|
|
.withErrorHandler(errorHandler)
|
|
}
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private extension OpenVPNView {
|
|
|
|
@ViewBuilder
|
|
var contentView: some View {
|
|
if let configuration = draft.wrappedValue.configurationBuilder {
|
|
ConfigurationView(
|
|
isServerPushed: isServerPushed,
|
|
configuration: configuration,
|
|
credentialsRoute: Subroute.credentials
|
|
)
|
|
} else {
|
|
importView
|
|
.modifier(providerModifier)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var importView: some View {
|
|
if providerId.wrappedValue == nil {
|
|
Button(Strings.Modules.General.Rows.importFromFile) {
|
|
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.cancel, role: .cancel) {
|
|
isImporting = false
|
|
}
|
|
},
|
|
message: {
|
|
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
var providerModifier: some ViewModifier {
|
|
VPNProviderContentModifier(
|
|
providerId: providerId,
|
|
selectedEntity: providerEntity,
|
|
paywallReason: $paywallReason,
|
|
entityDestination: Subroute.providerServer,
|
|
providerRows: {
|
|
moduleGroup(for: providerAccountRows)
|
|
}
|
|
)
|
|
}
|
|
|
|
var providerId: Binding<ProviderID?> {
|
|
editor.binding(forProviderOf: module.id)
|
|
}
|
|
|
|
var providerEntity: Binding<VPNEntity<OpenVPN.Configuration>?> {
|
|
editor.binding(forProviderEntityOf: module.id)
|
|
}
|
|
|
|
var providerAccountRows: [ModuleRow]? {
|
|
[.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
|
}
|
|
}
|
|
|
|
private extension OpenVPNView {
|
|
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
|
providerEntity.wrappedValue = 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 {
|
|
enum Subroute: Hashable {
|
|
case providerServer
|
|
|
|
case credentials
|
|
}
|
|
|
|
@ViewBuilder
|
|
func destination(for route: Subroute) -> some View {
|
|
switch route {
|
|
case .providerServer:
|
|
providerId.wrappedValue.map {
|
|
VPNProviderServerView(
|
|
moduleId: module.id,
|
|
providerId: $0,
|
|
configurationType: OpenVPN.Configuration.self,
|
|
selectedEntity: providerEntity.wrappedValue,
|
|
filtersWithSelection: true,
|
|
onSelect: onSelectServer
|
|
)
|
|
}
|
|
|
|
case .credentials:
|
|
Form {
|
|
OpenVPNCredentialsView(
|
|
isInteractive: draft.isInteractive,
|
|
credentials: draft.credentials
|
|
)
|
|
}
|
|
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
|
.themeForm()
|
|
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
// 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
|
|
|
|
let module = OpenVPNModule.Builder(configurationBuilder: builder)
|
|
return module.preview(title: "OpenVPN")
|
|
}
|
|
// swiftlint: enable force_try
|
|
|
|
private extension OpenVPN.CryptoContainer {
|
|
init(mockPem: String) {
|
|
self.init(pem: """
|
|
-----BEGIN CERTIFICATE-----
|
|
\(mockPem)
|
|
-----END CERTIFICATE-----
|
|
""")
|
|
}
|
|
}
|