diff --git a/Passepartout-iOS/Global/Downloader.swift b/Passepartout-iOS/Global/Downloader.swift new file mode 100644 index 00000000..dd879794 --- /dev/null +++ b/Passepartout-iOS/Global/Downloader.swift @@ -0,0 +1,101 @@ +// +// Downloader.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 4/10/19. +// Copyright (c) 2019 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 MBProgressHUD +import SwiftyBeaver +import Passepartout_Core + +private let log = SwiftyBeaver.self + +class Downloader: NSObject { + static let shared = Downloader(temporaryURL: GroupConstants.App.cachesURL.appendingPathComponent("downloaded.tmp")) + + private let temporaryURL: URL + + private var hud: MBProgressHUD? + + private var completionHandler: ((URL?, Error?) -> Void)? + + init(temporaryURL: URL) { + self.temporaryURL = temporaryURL + } + + func download(url: URL, in view: UIView, completionHandler: @escaping (URL?, Error?) -> Void) -> Bool { + guard hud == nil else { + log.info("Download in progress, skipping") + return false + } + + log.info("Downloading from: \(url)") + let session = URLSession(configuration: .default, delegate: self, delegateQueue: .main) + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: AppConstants.Web.timeout) + let task = session.downloadTask(with: request) + + hud = MBProgressHUD.showAdded(to: view, animated: true) + hud?.mode = .annularDeterminate + hud?.progressObject = task.progress + + self.completionHandler = completionHandler + task.resume() + return true + } +} + +extension Downloader: URLSessionDelegate, URLSessionDownloadDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + log.error("Download failed: \(error)") + hud?.hide(animated: true) + hud = nil + completionHandler?(nil, error) + completionHandler = nil + return + } + completionHandler = nil + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + log.info("Download complete!") + if let url = downloadTask.originalRequest?.url { + log.info("\tFrom: \(url)") + } + log.debug("\tTo: \(location)") + + let fm = FileManager.default + do { + try? fm.removeItem(at: temporaryURL) + try fm.copyItem(at: location, to: temporaryURL) + } catch let e { + log.error("Failed to copy downloaded file: \(e)") + return + } + + hud?.hide(animated: true) + hud = nil + completionHandler?(temporaryURL, nil) + completionHandler = nil + } +} diff --git a/Passepartout-iOS/Global/HUD.swift b/Passepartout-iOS/Global/HUD.swift index ba917b3a..3b06a243 100644 --- a/Passepartout-iOS/Global/HUD.swift +++ b/Passepartout-iOS/Global/HUD.swift @@ -40,12 +40,13 @@ import MBProgressHUD class HUD { private let backend: MBProgressHUD - init() { + init(label: String? = nil) { guard let window = UIApplication.shared.windows.first else { fatalError("Could not locate front window?") } backend = MBProgressHUD.showAdded(to: window, animated: true) + backend.label.text = label backend.backgroundView.backgroundColor = UIColor(white: 0.0, alpha: 0.6) backend.mode = .indeterminate backend.removeFromSuperViewOnHide = true diff --git a/Passepartout-iOS/Scenes/ServiceViewController.swift b/Passepartout-iOS/Scenes/ServiceViewController.swift index a5ea97ab..3d399db3 100644 --- a/Passepartout-iOS/Scenes/ServiceViewController.swift +++ b/Passepartout-iOS/Scenes/ServiceViewController.swift @@ -26,6 +26,7 @@ import UIKit import NetworkExtension import CoreTelephony +import MBProgressHUD import TunnelKit import Passepartout_Core @@ -247,7 +248,14 @@ class ServiceViewController: UIViewController, TableModelHost { } vpn.reconnect { (error) in guard error == nil else { - cell.setOn(false, animated: true) + + // XXX: delay to avoid weird toggle state + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + cell.setOn(false, animated: true) + if error as? ApplicationError == .externalResources { + self.requireDownload() + } + } return } self.reloadModel() @@ -439,6 +447,49 @@ class ServiceViewController: UIViewController, TableModelHost { IssueReporter.shared.present(in: self, withAttachments: attach) } + private func requireDownload() { + guard let providerProfile = profile as? ProviderConnectionProfile else { + return + } + guard let downloadURL = AppConstants.URLs.externalResources[providerProfile.name] else { + return + } + + let alert = Macros.alert( + L10n.Service.Alerts.Download.title, + L10n.Service.Alerts.Download.message(providerProfile.name.rawValue) + ) + alert.addCancelAction(L10n.Global.cancel) + alert.addDefaultAction(L10n.Global.ok) { + self.confirmDownload(URL(string: downloadURL)!) + } + present(alert, animated: true, completion: nil) + } + + private func confirmDownload(_ url: URL) { + _ = Downloader.shared.download(url: url, in: view) { (url, error) in + self.handleDownloadedProviderResources(url: url, error: error) + } + } + + private func handleDownloadedProviderResources(url: URL?, error: Error?) { + guard let url = url else { + let alert = Macros.alert( + L10n.Service.Alerts.Download.title, + L10n.Service.Alerts.Download.failed(error?.localizedDescription ?? "") + ) + alert.addCancelAction(L10n.Global.ok) + present(alert, animated: true, completion: nil) + return + } + + let hud = HUD(label: L10n.Service.Alerts.Download.Hud.extracting) + hud.show() + uncheckedProviderProfile.name.importExternalResources(from: url) { + hud.hide() + } + } + // MARK: Notifications @objc private func vpnDidUpdate() { diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 81b8d6ab..4d76924d 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 0EDE8DC420C86910004C739C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDE8DC320C86910004C739C /* PacketTunnelProvider.swift */; }; 0EDE8DC820C86910004C739C /* Passepartout-Tunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 0EDE8DBF20C86910004C739C /* Passepartout-Tunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0EE3BBB2215ED3A900F30952 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EE3BBB1215ED3A900F30952 /* AboutViewController.swift */; }; + 0EEB53B2225D525B00746300 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEB53B1225D525B00746300 /* Downloader.swift */; }; 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF56BBA2185AC8500B0C8AB /* SwiftGen+Segues.swift */; }; 0EF5CF252141CE58004FF1BD /* HUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF5CF242141CE58004FF1BD /* HUD.swift */; }; 0EF5CF292141F31F004FF1BD /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4FD7ED20D539A0002221FF /* Utils.swift */; }; @@ -276,6 +277,7 @@ 0EDE8DE620C93945004C739C /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; 0EDE8DED20C93E4C004C739C /* GroupConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConstants.swift; sourceTree = ""; }; 0EE3BBB1215ED3A900F30952 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; + 0EEB53B1225D525B00746300 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; 0EF56BBA2185AC8500B0C8AB /* SwiftGen+Segues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftGen+Segues.swift"; sourceTree = ""; }; 0EF5CF242141CE58004FF1BD /* HUD.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HUD.swift; sourceTree = ""; }; 0EFBFAC021AC464800887A8C /* CreditsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsViewController.swift; sourceTree = ""; }; @@ -547,6 +549,7 @@ 0EDE8DEC20C93E3B004C739C /* Global */ = { isa = PBXGroup; children = ( + 0EEB53B1225D525B00746300 /* Downloader.swift */, 0EF5CF242141CE58004FF1BD /* HUD.swift */, 0E24273F225951B00064A1A3 /* InApp.swift */, 0EFD943D215BE10800529B64 /* IssueReporter.swift */, @@ -1065,6 +1068,7 @@ 0ECC60DE2256B68A0020BEAC /* SwiftGen+Assets.swift in Sources */, 0E242742225956AC0064A1A3 /* DonationViewController.swift in Sources */, 0ED38AEC2141260D0004D387 /* ConfigurationModificationDelegate.swift in Sources */, + 0EEB53B2225D525B00746300 /* Downloader.swift in Sources */, 0ECEE45020E1182E00A6BB43 /* Theme+Cells.swift in Sources */, 0E242740225951B00064A1A3 /* InApp.swift in Sources */, 0E1066C920E0F84A004F98B7 /* Cells.swift in Sources */, diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index 1b952a3f..dbfa17d6 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -132,6 +132,10 @@ "service.alerts.data_count.messages.not_available" = "Information not available, are you connected?"; "service.alerts.masks_private_data.messages.must_reconnect" = "In order to safely reset the current debug log and apply the new masking preference, you must reconnect to the VPN now."; "service.alerts.buttons.reconnect" = "Reconnect"; +"service.alerts.download.title" = "Download required"; +"service.alerts.download.message" = "%@ requires the download of additional configuration files.\n\nConfirm to start the download."; +"service.alerts.download.failed" = "Failed to download configuration files. %@"; +"service.alerts.download.hud.extracting" = "Extracting files, please be patient..."; "account.sections.credentials.header" = "Credentials"; "account.sections.guidance.footer.infrastructure.mullvad" = "Use your %@ website account number and password \"m\"."; diff --git a/Passepartout/Sources/AppConstants.swift b/Passepartout/Sources/AppConstants.swift index bb2b952e..3cb0848b 100644 --- a/Passepartout/Sources/AppConstants.swift +++ b/Passepartout/Sources/AppConstants.swift @@ -202,6 +202,8 @@ public class AppConstants { .tunnelBear: "https://click.tunnelbear.com/aff_c?offer_id=2&aff_id=7464", .windscribe: "https://secure.link/kCsD0prd" ] + + public static let externalResources: [Infrastructure.Name: String] = [:] } public class Repos { diff --git a/Passepartout/Sources/ApplicationError.swift b/Passepartout/Sources/ApplicationError.swift index c34f15bc..93b33889 100644 --- a/Passepartout/Sources/ApplicationError.swift +++ b/Passepartout/Sources/ApplicationError.swift @@ -33,4 +33,6 @@ public enum ApplicationError: String, Error { case migration case inactiveProfile + + case externalResources } diff --git a/Passepartout/Sources/GroupConstants.swift b/Passepartout/Sources/GroupConstants.swift index 54cf1574..a6120a51 100644 --- a/Passepartout/Sources/GroupConstants.swift +++ b/Passepartout/Sources/GroupConstants.swift @@ -73,6 +73,8 @@ public class GroupConstants { try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) return url }() + + public static let externalURL = cachesURL.appendingPathComponent("External") } public class VPN { diff --git a/Passepartout/Sources/Model/Profiles/ProviderConnectionProfile.swift b/Passepartout/Sources/Model/Profiles/ProviderConnectionProfile.swift index 9516ed4c..c9fa49a4 100644 --- a/Passepartout/Sources/Model/Profiles/ProviderConnectionProfile.swift +++ b/Passepartout/Sources/Model/Profiles/ProviderConnectionProfile.swift @@ -120,6 +120,12 @@ public class ProviderConnectionProfile: ConnectionProfile, Codable, Equatable { builder.shouldDebug = configuration.shouldDebug builder.debugLogFormat = configuration.debugLogFormat builder.masksPrivateData = configuration.masksPrivateData + + do { + try preset.injectExternalConfiguration(&builder, with: name, pool: pool) + } catch { + throw ApplicationError.externalResources + } if let address = manualAddress { builder.prefersResolvedAddresses = true @@ -137,7 +143,7 @@ public class ProviderConnectionProfile: ConnectionProfile, Codable, Equatable { } else { // restrict "Any" protocol to UDP, unless there are no UDP endpoints - let allEndpoints = preset.configuration.sessionConfiguration.endpointProtocols + let allEndpoints = builder.sessionConfiguration.endpointProtocols var endpoints = allEndpoints?.filter { $0.socketType == .udp } if endpoints?.isEmpty ?? true { endpoints = allEndpoints diff --git a/Passepartout/Sources/Services/Infrastructure.swift b/Passepartout/Sources/Services/Infrastructure.swift index 260425d1..0249b688 100644 --- a/Passepartout/Sources/Services/Infrastructure.swift +++ b/Passepartout/Sources/Services/Infrastructure.swift @@ -37,14 +37,6 @@ public struct Infrastructure: Codable { case tunnelBear = "TunnelBear" case windscribe = "Windscribe" - - public var webName: String { - return rawValue.lowercased() - } - - public static func <(lhs: Name, rhs: Name) -> Bool { - return lhs.webName < rhs.webName - } } public struct Defaults: Codable { @@ -78,3 +70,36 @@ public struct Infrastructure: Codable { return presets.first { $0.id == identifier } } } + +extension Infrastructure.Name { + public var webName: String { + return rawValue.lowercased() + } + + public static func <(lhs: Infrastructure.Name, rhs: Infrastructure.Name) -> Bool { + return lhs.webName < rhs.webName + } +} + +extension Infrastructure.Name { + public var externalURL: URL { + return GroupConstants.App.externalURL.appendingPathComponent(webName) + } + + public func importExternalResources(from url: URL, completionHandler: @escaping () -> Void) { + switch self { + default: + break + } + } + + private func execute(task: @escaping () -> Void, completionHandler: @escaping () -> Void) { + let queue: DispatchQueue = .global(qos: .background) + queue.async { + task() + DispatchQueue.main.async { + completionHandler() + } + } + } +} diff --git a/Passepartout/Sources/Services/InfrastructurePreset.swift b/Passepartout/Sources/Services/InfrastructurePreset.swift index 106ea43a..d37d581d 100644 --- a/Passepartout/Sources/Services/InfrastructurePreset.swift +++ b/Passepartout/Sources/Services/InfrastructurePreset.swift @@ -30,6 +30,16 @@ import TunnelKit // ignores new JSON keys public struct InfrastructurePreset: Codable { + public enum ExternalKey: String, Codable { + case ca + + case client + + case key + + case wrapKeyData = "wrap.key.data" + } + public enum PresetKeys: String, CodingKey { case id @@ -38,6 +48,8 @@ public struct InfrastructurePreset: Codable { case comment case configuration = "cfg" + + case external } public enum ConfigurationKeys: String, CodingKey { @@ -78,10 +90,38 @@ public struct InfrastructurePreset: Codable { public let configuration: TunnelKitProvider.Configuration + public let external: [ExternalKey: String]? + public func hasProtocol(_ proto: EndpointProtocol) -> Bool { return configuration.sessionConfiguration.endpointProtocols?.firstIndex(of: proto) != nil } + public func injectExternalConfiguration(_ configuration: inout TunnelKitProvider.ConfigurationBuilder, with name: Infrastructure.Name, pool: Pool) throws { + guard let external = external, !external.isEmpty else { + return + } + + let baseURL = name.externalURL + + var sessionBuilder = configuration.sessionConfiguration.builder() + if let pattern = external[.ca] { + let filename = pattern.replacingOccurrences(of: "${id}", with: pool.id) + let caURL = baseURL.appendingPathComponent(filename) + sessionBuilder.ca = CryptoContainer(pem: try String(contentsOf: caURL)) + } + if let pattern = external[.wrapKeyData] { + let filename = pattern.replacingOccurrences(of: "${id}", with: pool.id) + let tlsKeyURL = baseURL.appendingPathComponent(filename) + if let dummyWrap = sessionBuilder.tlsWrap { + let file = try String(contentsOf: tlsKeyURL) + if let staticKey = StaticKey(file: file, direction: .client) { + sessionBuilder.tlsWrap = SessionProxy.TLSWrap(strategy: dummyWrap.strategy, key: staticKey) + } + } + } + configuration.sessionConfiguration = sessionBuilder.build() + } + // MARK: Codable public init(from decoder: Decoder) throws { @@ -89,6 +129,18 @@ public struct InfrastructurePreset: Codable { id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) comment = try container.decode(String.self, forKey: .comment) + if let rawExternal = try container.decodeIfPresent([String: String].self, forKey: .external) { + var remapped: [ExternalKey: String] = [:] + for entry in rawExternal { + guard let key = ExternalKey(rawValue: entry.key) else { + continue + } + remapped[key] = entry.value + } + external = remapped + } else { + external = nil + } let cfgContainer = try container.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration) @@ -99,7 +151,7 @@ public struct InfrastructurePreset: Codable { } sessionBuilder.compressionFraming = try cfgContainer.decode(SessionProxy.CompressionFraming.self, forKey: .compressionFraming) sessionBuilder.compressionAlgorithm = try cfgContainer.decodeIfPresent(SessionProxy.CompressionAlgorithm.self, forKey: .compressionAlgorithm) ?? .disabled - sessionBuilder.ca = try cfgContainer.decode(CryptoContainer.self, forKey: .ca) + sessionBuilder.ca = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .ca) sessionBuilder.clientCertificate = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientCertificate) sessionBuilder.clientKey = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientKey) sessionBuilder.tlsWrap = try cfgContainer.decodeIfPresent(SessionProxy.TLSWrap.self, forKey: .tlsWrap) @@ -126,13 +178,14 @@ public struct InfrastructurePreset: Codable { try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(comment, forKey: .comment) + try container.encodeIfPresent(external, forKey: .external) var cfgContainer = container.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration) try cfgContainer.encode(configuration.sessionConfiguration.cipher, forKey: .cipher) try cfgContainer.encode(configuration.sessionConfiguration.digest, forKey: .digest) try cfgContainer.encode(configuration.sessionConfiguration.compressionFraming, forKey: .compressionFraming) try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.compressionAlgorithm, forKey: .compressionAlgorithm) - try cfgContainer.encode(ca, forKey: .ca) + try cfgContainer.encodeIfPresent(ca, forKey: .ca) try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientCertificate, forKey: .clientCertificate) try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientKey, forKey: .clientKey) try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.tlsWrap, forKey: .tlsWrap) diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index b2bd6a0a..eeb4e1b4 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -593,6 +593,22 @@ public enum L10n { public static let notAvailable = L10n.tr("Localizable", "service.alerts.data_count.messages.not_available") } } + public enum Download { + /// Failed to download configuration files. %@ + public static func failed(_ p1: String) -> String { + return L10n.tr("Localizable", "service.alerts.download.failed", p1) + } + /// %@ requires the download of additional configuration files.\n\nConfirm to start the download. + public static func message(_ p1: String) -> String { + return L10n.tr("Localizable", "service.alerts.download.message", p1) + } + /// Download required + public static let title = L10n.tr("Localizable", "service.alerts.download.title") + public enum Hud { + /// Extracting files, please be patient... + public static let extracting = L10n.tr("Localizable", "service.alerts.download.hud.extracting") + } + } public enum MasksPrivateData { public enum Messages { /// In order to safely reset the current debug log and apply the new masking preference, you must reconnect to the VPN now. diff --git a/Passepartout/Sources/VPN/GracefulVPN.swift b/Passepartout/Sources/VPN/GracefulVPN.swift index 863b28f5..e8dc8258 100644 --- a/Passepartout/Sources/VPN/GracefulVPN.swift +++ b/Passepartout/Sources/VPN/GracefulVPN.swift @@ -75,6 +75,10 @@ public class GracefulVPN { log.info("Reconnecting...") try vpn.reconnect(configuration: service.vpnConfiguration(), completionHandler: completionHandler) } catch let e { + guard e as? ApplicationError != .externalResources else { + completionHandler?(e) + return + } log.error("Could not reconnect: \(e)") } } @@ -89,6 +93,10 @@ public class GracefulVPN { log.info("Reinstalling...") try vpn.install(configuration: service.vpnConfiguration(), completionHandler: completionHandler) } catch let e { + guard e as? ApplicationError != .externalResources else { + completionHandler?(e) + return + } log.error("Could not reinstall: \(e)") } }