passepartout-apple/Passepartout/Core/Sources/Model/ConnectionService.swift
2021-01-27 02:17:45 +01:00

684 lines
23 KiB
Swift

//
// ConnectionService.swift
// Passepartout
//
// Created by Davide De Rosa on 9/3/18.
// Copyright (c) 2021 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 TunnelKit
import NetworkExtension
import SwiftyBeaver
private let log = SwiftyBeaver.self
public protocol ConnectionServiceDelegate: class {
func connectionService(didAdd profile: ConnectionProfile)
func connectionService(didRename profile: ConnectionProfile, to newTitle: String)
func connectionService(didRemoveProfileWithKey key: ProfileKey)
func connectionService(willDeactivate profile: ConnectionProfile)
func connectionService(didActivate profile: ConnectionProfile)
func connectionService(didUpdate profile: ConnectionProfile)
}
public class ConnectionService: Codable {
public enum CodingKeys: String, CodingKey {
case build
case appGroup
case baseConfiguration
case activeProfileKey
case preferences
case hostTitles
}
public struct NotificationKeys {
public static let dataCount = "DataCount"
}
public static let didUpdateDataCount = Notification.Name("ConnectionServiceDidUpdateDataCount")
public var directory: String? = nil
public var rootURL: URL {
var url = GroupConstants.App.documentsURL
if let directory = directory {
url.appendPathComponent(directory)
}
return url
}
var providersURL: URL {
return rootURL.appendingPathComponent(AppConstants.Store.providersDirectory)
}
var hostsURL: URL {
return rootURL.appendingPathComponent(AppConstants.Store.hostsDirectory)
}
private var build: Int
private let appGroup: String
private let defaults: UserDefaults
private let keychain: Keychain
public var baseConfiguration: OpenVPNTunnelProvider.Configuration
private var cache: [ProfileKey: ConnectionProfile]
// XXX: access needed by +Migration
var hostTitles: [String: String]
public internal(set) var activeProfileKey: ProfileKey? {
willSet {
if let oldProfile = activeProfile {
delegate?.connectionService(willDeactivate: oldProfile)
}
}
didSet {
if let newProfile = activeProfile {
delegate?.connectionService(didActivate: newProfile)
}
}
}
public var activeProfile: ConnectionProfile? {
guard let id = activeProfileKey else {
return nil
}
var hit = cache[id]
if let placeholder = hit as? PlaceholderConnectionProfile {
hit = profile(withContext: placeholder.context, id: placeholder.id)
cache[id] = hit
}
return hit
}
public let preferences: EditablePreferences
public weak var delegate: ConnectionServiceDelegate?
public init(withAppGroup appGroup: String, baseConfiguration: OpenVPNTunnelProvider.Configuration) {
guard let defaults = UserDefaults(suiteName: appGroup) else {
fatalError("No entitlements for group '\(appGroup)'")
}
build = GroupConstants.App.buildNumber
self.appGroup = appGroup
self.defaults = defaults
keychain = Keychain(group: appGroup)
self.baseConfiguration = baseConfiguration
activeProfileKey = nil
preferences = EditablePreferences()
cache = [:]
hostTitles = [:]
ensureDirectoriesExistence()
}
// MARK: Codable
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let appGroup = try container.decode(String.self, forKey: .appGroup)
guard let defaults = UserDefaults(suiteName: appGroup) else {
fatalError("No entitlements for group '\(appGroup)'")
}
build = try container.decode(Int.self, forKey: .build)
self.appGroup = appGroup
self.defaults = defaults
keychain = Keychain(group: appGroup)
baseConfiguration = try container.decode(OpenVPNTunnelProvider.Configuration.self, forKey: .baseConfiguration)
activeProfileKey = try container.decodeIfPresent(ProfileKey.self, forKey: .activeProfileKey)
preferences = try container.decode(EditablePreferences.self, forKey: .preferences)
cache = [:]
hostTitles = try container.decode([String: String].self, forKey: .hostTitles)
ensureDirectoriesExistence()
}
public func encode(to encoder: Encoder) throws {
build = GroupConstants.App.buildNumber
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(build, forKey: .build)
try container.encode(appGroup, forKey: .appGroup)
try container.encode(baseConfiguration, forKey: .baseConfiguration)
try container.encodeIfPresent(activeProfileKey, forKey: .activeProfileKey)
try container.encode(preferences, forKey: .preferences)
try container.encode(hostTitles, forKey: .hostTitles)
}
// MARK: Serialization
public func loadProfiles() {
let fm = FileManager.default
do {
let files = try fm.contentsOfDirectory(at: providersURL, includingPropertiesForKeys: nil, options: [])
// log.debug("Found \(files.count) provider files: \(files)")
for entry in files {
guard let id = ConnectionService.profileId(fromURL: entry) else {
continue
}
let key = ProfileKey(.provider, id)
cache[key] = PlaceholderConnectionProfile(key)
}
} catch let e {
log.warning("Could not list provider contents: \(e) (\(providersURL))")
}
do {
let files = try fm.contentsOfDirectory(at: hostsURL, includingPropertiesForKeys: nil, options: [])
// log.debug("Found \(files.count) host files: \(files)")
for entry in files {
guard let id = ConnectionService.profileId(fromURL: entry) else {
continue
}
let key = ProfileKey(.host, id)
cache[key] = PlaceholderConnectionProfile(key)
}
} catch let e {
log.warning("Could not list host contents: \(e) (\(hostsURL))")
}
// clean up hostTitles if necessary
let staleHostIds = hostTitles.keys.filter { cache[ProfileKey(.host, $0)] == nil }
staleHostIds.forEach {
hostTitles.removeValue(forKey: $0)
}
}
public func saveProfiles() {
let encoder = JSONEncoder()
for profile in cache.values {
saveProfile(profile, withEncoder: encoder)
}
}
private func ensureDirectoriesExistence() {
let fm = FileManager.default
do {
try fm.createDirectory(at: providersURL, withIntermediateDirectories: false, attributes: nil)
} catch let e {
log.warning("Could not create providers folder: \(e) (\(providersURL))")
}
do {
try fm.createDirectory(at: hostsURL, withIntermediateDirectories: false, attributes: nil)
} catch let e {
log.warning("Could not create hosts folder: \(e) (\(hostsURL))")
}
}
private func saveProfile(_ profile: ConnectionProfile, withEncoder encoder: JSONEncoder) {
do {
let url = profileURL(ProfileKey(profile))
var optData: Data?
if let providerProfile = profile as? ProviderConnectionProfile {
optData = try encoder.encode(providerProfile)
} else if let hostProfile = profile as? HostConnectionProfile {
optData = try encoder.encode(hostProfile)
} else if let placeholder = profile as? PlaceholderConnectionProfile {
log.debug("Skipped placeholder \(placeholder)")
} else {
fatalError("Attempting to add an unhandled profile type: \(type(of: profile))")
}
guard let data = optData else {
return
}
try data.write(to: url)
log.debug("Serialized profile \(profile)")
} catch let e {
log.warning("Could not serialize profile \(profile): \(e)")
}
}
public func profile(withContext context: Context, id: String) -> ConnectionProfile? {
return profile(withKey: ProfileKey(context, id))
}
public func profile(withKey key: ProfileKey) -> ConnectionProfile? {
var profile = cache[key]
if let _ = profile as? PlaceholderConnectionProfile {
let decoder = JSONDecoder()
do {
let data = try profileData(key)
switch key.context {
case .provider:
let providerProfile = try decoder.decode(ProviderConnectionProfile.self, from: data)
// XXX: fix renamed presets, fall back to default
if providerProfile.preset == nil {
providerProfile.presetId = providerProfile.infrastructure.defaults.preset
}
// XXX: fix renamed pool, fall back to default
if providerProfile.pool == nil, let fallbackPool = providerProfile.infrastructure.defaultPool() {
providerProfile.poolId = fallbackPool.id
}
// XXX: fix unsupported preset
providerProfile.setSupportedPreset()
// XXX: patch empty favorites
if providerProfile.favoriteGroupIds == nil {
providerProfile.favoriteGroupIds = []
}
profile = providerProfile
case .host:
let hostProfile = try decoder.decode(HostConnectionProfile.self, from: data)
profile = hostProfile
}
cache[key] = profile
} catch let e {
log.error("Could not decode profile JSON: \(e)")
// // drop corrupt cache entry
// cache.removeValue(forKey: key)
// try? FileManager.default.removeItem(at: profileURL(key))
return nil
}
}
// XXX: preload trusted networks in a backwards compatible manner (deserialization)
if profile?.trustedNetworks == nil {
profile?.trustedNetworks = TrustedNetworks()
}
// propagate delegate
profile?.serviceDelegate = delegate
return profile
}
public func allProfileKeys() -> [ProfileKey] {
return Array(cache.keys)
}
public func ids(forContext context: Context) -> [String] {
return cache.keys.filter { $0.context == context }.map { $0.id }
}
public func contextURL(_ key: ProfileKey) -> URL {
switch key.context {
case .provider:
return providersURL
case .host:
return hostsURL
}
}
public func profileURL(_ key: ProfileKey) -> URL {
return contextURL(key).appendingPathComponent(key.id).appendingPathExtension("json")
}
public func profileData(_ key: ProfileKey) throws -> Data {
return try Data(contentsOf: profileURL(key))
}
private static func profileId(fromURL url: URL) -> String? {
guard url.pathExtension == "json" else {
return nil
}
return url.deletingPathExtension().lastPathComponent
}
// MARK: Profiles
public func hasProfiles() -> Bool {
return !cache.isEmpty
}
public func addProfile(_ profile: ConnectionProfile, credentials: Credentials?) -> Bool {
guard cache.index(forKey: ProfileKey(profile)) == nil else {
return false
}
addOrReplaceProfile(profile, credentials: credentials)
return true
}
public func addOrReplaceProfile(_ profile: ConnectionProfile, credentials: Credentials?, title: String? = nil) {
let key = ProfileKey(profile)
cache[key] = profile
if key.context == .host {
hostTitles[key.id] = title
}
try? setCredentials(credentials, for: profile)
if cache.count == 1 {
activeProfileKey = key
}
delegate?.connectionService(didAdd: profile)
// serialization (can fail)
saveProfile(profile, withEncoder: JSONEncoder())
}
public func renameProfile(_ key: ProfileKey, to newTitle: String) {
precondition(key.context == .host, "Can only rename a HostConnectionProfile")
guard let profile = cache[key] else {
return
}
hostTitles[key.id] = newTitle
delegate?.connectionService(didRename: profile, to: newTitle)
}
public func renameProfile(_ profile: ConnectionProfile, to newTitle: String) {
renameProfile(ProfileKey(profile), to: newTitle)
}
public func removeProfile(_ key: ProfileKey) {
guard let profile = cache[key] else {
return
}
if key == activeProfileKey {
activeProfileKey = nil
}
cache.removeValue(forKey: key)
if key.context == .host {
hostTitles.removeValue(forKey: key.id)
}
removeCredentials(for: profile)
delegate?.connectionService(didRemoveProfileWithKey: key)
// serialization (can fail)
do {
let fm = FileManager.default
if let cfg = configurationURL(for: key) {
try? fm.removeItem(at: cfg)
}
let url = profileURL(key)
try fm.removeItem(at: url)
log.debug("Deleted removed profile '\(profile.id)'")
} catch let e {
log.warning("Could not delete profile '\(profile.id)': \(e)")
}
}
public func containsProfile(_ key: ProfileKey) -> Bool {
return cache.index(forKey: key) != nil
}
public func containsProfile(_ profile: ConnectionProfile) -> Bool {
return containsProfile(ProfileKey(profile))
}
public func hasActiveProfile() -> Bool {
return activeProfileKey != nil
}
public func isActiveProfile(_ key: ProfileKey) -> Bool {
return key == activeProfileKey
}
public func isActiveProfile(_ profile: ConnectionProfile) -> Bool {
return isActiveProfile(ProfileKey(profile))
}
public func activateProfile(_ profile: ConnectionProfile) {
activeProfileKey = ProfileKey(profile)
}
public func existingHostId(withTitle title: String) -> String? {
for id in hostTitles.keys {
guard let _ = cache[ProfileKey(.host, id)] else {
continue
}
if hostTitles[id] == title {
return id
}
}
return nil
}
public func hostProfile(withTitle title: String) -> HostConnectionProfile? {
guard let id = existingHostId(withTitle: title) else {
return nil
}
return profile(withContext: .host, id: id) as? HostConnectionProfile
}
// MARK: Credentials
public func needsCredentials(for profile: ConnectionProfile) -> Bool {
guard profile.requiresCredentials else {
return false
}
guard let creds = credentials(for: profile) else {
return true
}
return !creds.isValid
}
public func credentials(for profile: ConnectionProfile) -> Credentials? {
guard let username = profile.username else {
return nil
}
let password = (try? keychain.password(for: username, context: profile.passwordContext)) ?? "" // make password optional
return Credentials(username, password)
}
public func setCredentials(_ credentials: Credentials?, for profile: ConnectionProfile) throws {
profile.username = credentials?.username
try profile.setPassword(credentials?.password, in: keychain)
}
public func removeCredentials(for profile: ConnectionProfile) {
profile.removePassword(in: keychain)
}
// MARK: VPN
public func vpnConfiguration() throws -> NetworkExtensionVPNConfiguration {
guard let profile = activeProfile else {
throw ApplicationError.missingProfile
}
let creds = credentials(for: profile)
if profile.requiresCredentials {
guard creds != nil else {
throw ApplicationError.missingCredentials
}
}
var cfg = try profile.generate(from: baseConfiguration, preferences: preferences)
// override network settings
if let choices = profile.networkChoices, let settings = profile.manualNetworkSettings {
var builder = cfg.builder()
var sessionBuilder = builder.sessionConfiguration.builder()
// enforce default gateway for providers unless "Manual"
if type(of: profile) == ProviderConnectionProfile.self {
if choices.gateway == .manual {
sessionBuilder.applyGateway(from: choices, settings: settings)
}
} else {
sessionBuilder.applyGateway(from: choices, settings: settings)
}
sessionBuilder.applyDNS(from: choices, settings: settings)
sessionBuilder.applyProxy(from: choices, settings: settings)
sessionBuilder.applyMTU(from: choices, settings: settings)
builder.sessionConfiguration = sessionBuilder.build()
cfg = builder.build()
}
let protocolConfiguration = try cfg.generatedTunnelProtocol(
withBundleIdentifier: AppConstants.App.tunnelBundleId,
appGroup: appGroup,
context: profile.passwordContext,
username: creds?.username
)
protocolConfiguration.disconnectOnSleep = preferences.disconnectsOnSleep
log.verbose("Configuration:")
log.verbose(protocolConfiguration)
var rules: [NEOnDemandRule] = []
#if os(iOS)
if profile.trustedNetworks.includesMobile {
let rule = policyRule(for: profile)
rule.interfaceTypeMatch = .cellular
rules.append(rule)
}
#else
if profile.trustedNetworks.includesEthernet {
let rule = policyRule(for: profile)
rule.interfaceTypeMatch = .ethernet
rules.append(rule)
}
#endif
let reallyTrustedWifis = Array(profile.trustedNetworks.includedWiFis.filter { $1 }.keys)
if !reallyTrustedWifis.isEmpty {
let rule = policyRule(for: profile)
rule.interfaceTypeMatch = .wiFi
rule.ssidMatch = reallyTrustedWifis
rules.append(rule)
}
let connection = NEOnDemandRuleConnect()
connection.interfaceTypeMatch = .any
rules.append(connection)
return NetworkExtensionVPNConfiguration(
title: screenTitle(ProfileKey(profile)),
protocolConfiguration: protocolConfiguration,
onDemandRules: rules
)
}
private func policyRule(for profile: ConnectionProfile) -> NEOnDemandRule {
switch profile.trustedNetworks.policy {
case .ignore:
return NEOnDemandRuleIgnore()
case .disconnect:
return NEOnDemandRuleDisconnect()
}
}
public var vpnLog: String {
return baseConfiguration.existingLog(in: appGroup) ?? ""
}
public func eraseVpnLog() {
log.info("Erasing VPN log...")
guard let url = baseConfiguration.urlForLog(in: appGroup) else {
return
}
try? FileManager.default.removeItem(at: url)
}
public var vpnLastError: OpenVPNTunnelProvider.ProviderError? {
return baseConfiguration.lastError(in: appGroup)
}
public func clearVpnLastError() {
baseConfiguration.clearLastError(in: appGroup)
}
public func observeVPNDataCount(milliseconds: Int) {
reportDataCountAndRepeat(after: milliseconds)
}
private func reportDataCountAndRepeat(after milliseconds: Int) {
reportDataCount()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds)) { [weak self] in
self?.reportDataCountAndRepeat(after: milliseconds)
}
}
private func reportDataCount() {
guard let dataCount = vpnDataCount else {
return
}
NotificationCenter.default.post(name: ConnectionService.didUpdateDataCount, object: nil, userInfo: [NotificationKeys.dataCount: dataCount])
}
public var vpnDataCount: (Int, Int)? {
return baseConfiguration.dataCount(in: appGroup)
}
}
public extension ConnectionService {
func providerNames() -> [Infrastructure.Name] {
return ids(forContext: .provider)
}
func hostIds() -> [String] {
return ids(forContext: .host)
}
func sortedProviderNames() -> [Infrastructure.Name] {
return providerNames().sorted()
}
func sortedHostIds() -> [String] {
return hostIds().sorted {
let title1 = screenTitle(ProfileKey(.host, $0))
let title2 = screenTitle(ProfileKey(.host, $1))
return title1.lowercased() < title2.lowercased()
}
}
func screenTitle(forHostId id: String) -> String {
return screenTitle(ProfileKey(.host, id))
}
func screenTitle(forProviderName name: Infrastructure.Name) -> String {
return screenTitle(ProfileKey(.provider, name))
}
func screenTitle(_ key: ProfileKey) -> String {
switch key.context {
case .provider:
if let metadata = InfrastructureFactory.shared.metadata(forName: key.id) {
return metadata.description
}
case .host:
if let title = hostTitles[key.id] {
return title
}
}
return key.id
}
}