diff options
author | Andrej Mihajlov <and@mullvad.net> | 2020-12-02 12:27:39 +0100 |
---|---|---|
committer | Andrej Mihajlov <and@mullvad.net> | 2020-12-03 13:32:24 +0100 |
commit | ec574085703ea1c8b2d4538596961beb910c4382 (patch) | |
tree | 73cf8bbdb74fe5575606664bccd0232ffa911803 /Sources/WireGuardApp/Tunnel | |
parent | WireGuardKit: Assert that resolutionResults must not contain failures (diff) | |
download | wireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.tar.xz wireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.zip |
Move all source files to `Sources/` and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
Diffstat (limited to 'Sources/WireGuardApp/Tunnel')
-rw-r--r-- | Sources/WireGuardApp/Tunnel/ActivateOnDemandOption.swift | 133 | ||||
-rw-r--r-- | Sources/WireGuardApp/Tunnel/MockTunnels.swift | 49 | ||||
-rw-r--r-- | Sources/WireGuardApp/Tunnel/TunnelConfiguration+UapiConfig.swift | 185 | ||||
-rw-r--r-- | Sources/WireGuardApp/Tunnel/TunnelErrors.swift | 107 | ||||
-rw-r--r-- | Sources/WireGuardApp/Tunnel/TunnelStatus.swift | 63 | ||||
-rw-r--r-- | Sources/WireGuardApp/Tunnel/TunnelsManager.swift | 807 |
6 files changed, 1344 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/Tunnel/ActivateOnDemandOption.swift b/Sources/WireGuardApp/Tunnel/ActivateOnDemandOption.swift new file mode 100644 index 0000000..d44e1d6 --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/ActivateOnDemandOption.swift @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import NetworkExtension + +enum ActivateOnDemandOption: Equatable { + case off + case wiFiInterfaceOnly(ActivateOnDemandSSIDOption) + case nonWiFiInterfaceOnly + case anyInterface(ActivateOnDemandSSIDOption) +} + +#if os(iOS) +private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .cellular +#elseif os(macOS) +private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .ethernet +#else +#error("Unimplemented") +#endif + +enum ActivateOnDemandSSIDOption: Equatable { + case anySSID + case onlySpecificSSIDs([String]) + case exceptSpecificSSIDs([String]) +} + +extension ActivateOnDemandOption { + func apply(on tunnelProviderManager: NETunnelProviderManager) { + let rules: [NEOnDemandRule]? + switch self { + case .off: + rules = nil + case .wiFiInterfaceOnly(let ssidOption): + rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleDisconnect(interfaceType: nonWiFiInterfaceType)] + case .nonWiFiInterfaceOnly: + rules = [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType), NEOnDemandRuleDisconnect(interfaceType: .wiFi)] + case .anyInterface(let ssidOption): + if case .anySSID = ssidOption { + rules = [NEOnDemandRuleConnect(interfaceType: .any)] + } else { + rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType)] + } + } + tunnelProviderManager.onDemandRules = rules + tunnelProviderManager.isOnDemandEnabled = self != .off + } + + init(from tunnelProviderManager: NETunnelProviderManager) { + if tunnelProviderManager.isOnDemandEnabled, let onDemandRules = tunnelProviderManager.onDemandRules { + self = ActivateOnDemandOption.create(from: onDemandRules) + } else { + self = .off + } + } + + private static func create(from rules: [NEOnDemandRule]) -> ActivateOnDemandOption { + switch rules.count { + case 0: + return .off + case 1: + let rule = rules[0] + guard rule.action == .connect else { return .off } + return .anyInterface(.anySSID) + case 2: + guard let connectRule = rules.first(where: { $0.action == .connect }) else { + wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no connect rule.") + return .off + } + guard let disconnectRule = rules.first(where: { $0.action == .disconnect }) else { + wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no disconnect rule.") + return .off + } + if connectRule.interfaceTypeMatch == .wiFi && disconnectRule.interfaceTypeMatch == nonWiFiInterfaceType { + return .wiFiInterfaceOnly(.anySSID) + } else if connectRule.interfaceTypeMatch == nonWiFiInterfaceType && disconnectRule.interfaceTypeMatch == .wiFi { + return .nonWiFiInterfaceOnly + } else { + wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but interface types are inconsistent.") + return .off + } + case 3: + guard let ssidRule = rules.first(where: { $0.interfaceTypeMatch == .wiFi && $0.ssidMatch != nil }) else { return .off } + guard let nonWiFiRule = rules.first(where: { $0.interfaceTypeMatch == nonWiFiInterfaceType }) else { return .off } + let ssids = ssidRule.ssidMatch! + switch (ssidRule.action, nonWiFiRule.action) { + case (.connect, .connect): + return .anyInterface(.onlySpecificSSIDs(ssids)) + case (.connect, .disconnect): + return .wiFiInterfaceOnly(.onlySpecificSSIDs(ssids)) + case (.disconnect, .connect): + return .anyInterface(.exceptSpecificSSIDs(ssids)) + case (.disconnect, .disconnect): + return .wiFiInterfaceOnly(.exceptSpecificSSIDs(ssids)) + default: + wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found") + return .off + } + default: + wg_log(.error, message: "Unexpected number of onDemandRules set on tunnel provider manager: \(rules.count) rules found") + return .off + } + } +} + +private extension NEOnDemandRuleConnect { + convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) { + self.init() + interfaceTypeMatch = interfaceType + ssidMatch = ssids + } +} + +private extension NEOnDemandRuleDisconnect { + convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) { + self.init() + interfaceTypeMatch = interfaceType + ssidMatch = ssids + } +} + +private func ssidOnDemandRules(option: ActivateOnDemandSSIDOption) -> [NEOnDemandRule] { + switch option { + case .anySSID: + return [NEOnDemandRuleConnect(interfaceType: .wiFi)] + case .onlySpecificSSIDs(let ssids): + assert(!ssids.isEmpty) + return [NEOnDemandRuleConnect(interfaceType: .wiFi, ssids: ssids), + NEOnDemandRuleDisconnect(interfaceType: .wiFi)] + case .exceptSpecificSSIDs(let ssids): + return [NEOnDemandRuleDisconnect(interfaceType: .wiFi, ssids: ssids), + NEOnDemandRuleConnect(interfaceType: .wiFi)] + } +} diff --git a/Sources/WireGuardApp/Tunnel/MockTunnels.swift b/Sources/WireGuardApp/Tunnel/MockTunnels.swift new file mode 100644 index 0000000..1ffa99c --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/MockTunnels.swift @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import NetworkExtension +import WireGuardKit + +// Creates mock tunnels for the iOS Simulator. + +#if targetEnvironment(simulator) +class MockTunnels { + static let tunnelNames = [ + "demo", + "edgesecurity", + "home", + "office", + "infra-fr", + "infra-us", + "krantz", + "metheny", + "frisell" + ] + static let address = "192.168.%d.%d/32" + static let dnsServers = ["8.8.8.8", "8.8.4.4"] + static let endpoint = "demo.wireguard.com:51820" + static let allowedIPs = "0.0.0.0/0" + + static func createMockTunnels() -> [NETunnelProviderManager] { + return tunnelNames.map { tunnelName -> NETunnelProviderManager in + + var interface = InterfaceConfiguration(privateKey: PrivateKey()) + interface.addresses = [IPAddressRange(from: String(format: address, Int.random(in: 1 ... 10), Int.random(in: 1 ... 254)))!] + interface.dns = dnsServers.map { DNSServer(from: $0)! } + + var peer = PeerConfiguration(publicKey: PrivateKey().publicKey) + peer.endpoint = Endpoint(from: endpoint) + peer.allowedIPs = [IPAddressRange(from: allowedIPs)!] + + let tunnelConfiguration = TunnelConfiguration(name: tunnelName, interface: interface, peers: [peer]) + + let tunnelProviderManager = NETunnelProviderManager() + tunnelProviderManager.protocolConfiguration = NETunnelProviderProtocol(tunnelConfiguration: tunnelConfiguration) + tunnelProviderManager.localizedDescription = tunnelConfiguration.name + tunnelProviderManager.isEnabled = true + + return tunnelProviderManager + } + } +} +#endif diff --git a/Sources/WireGuardApp/Tunnel/TunnelConfiguration+UapiConfig.swift b/Sources/WireGuardApp/Tunnel/TunnelConfiguration+UapiConfig.swift new file mode 100644 index 0000000..fcd6a31 --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/TunnelConfiguration+UapiConfig.swift @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import WireGuardKit + +extension TunnelConfiguration { + convenience init(fromUapiConfig uapiConfig: String, basedOn base: TunnelConfiguration? = nil) throws { + var interfaceConfiguration: InterfaceConfiguration? + var peerConfigurations = [PeerConfiguration]() + + var lines = uapiConfig.split(separator: "\n") + lines.append("") + + var parserState = ParserState.inInterfaceSection + var attributes = [String: String]() + + for line in lines { + var key = "" + var value = "" + + if !line.isEmpty { + guard let equalsIndex = line.firstIndex(of: "=") else { throw ParseError.invalidLine(line) } + key = String(line[..<equalsIndex]) + value = String(line[line.index(equalsIndex, offsetBy: 1)...]) + } + + if line.isEmpty || key == "public_key" { + // Previous section has ended; process the attributes collected so far + if parserState == .inInterfaceSection { + let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes) + guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces } + interfaceConfiguration = interface + parserState = .inPeerSection + } else if parserState == .inPeerSection { + let peer = try TunnelConfiguration.collate(peerAttributes: attributes) + peerConfigurations.append(peer) + } + attributes.removeAll() + if line.isEmpty { + break + } + } + + if let presentValue = attributes[key] { + if key == "allowed_ip" { + attributes[key] = presentValue + "," + value + } else { + throw ParseError.multipleEntriesForKey(key) + } + } else { + attributes[key] = value + } + + let interfaceSectionKeys: Set<String> = ["private_key", "listen_port", "fwmark"] + let peerSectionKeys: Set<String> = ["public_key", "preshared_key", "allowed_ip", "endpoint", "persistent_keepalive_interval", "last_handshake_time_sec", "last_handshake_time_nsec", "rx_bytes", "tx_bytes", "protocol_version"] + + if parserState == .inInterfaceSection { + guard interfaceSectionKeys.contains(key) else { + throw ParseError.interfaceHasUnrecognizedKey(key) + } + } + if parserState == .inPeerSection { + guard peerSectionKeys.contains(key) else { + throw ParseError.peerHasUnrecognizedKey(key) + } + } + } + + let peerPublicKeysArray = peerConfigurations.map { $0.publicKey } + let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray) + if peerPublicKeysArray.count != peerPublicKeysSet.count { + throw ParseError.multiplePeersWithSamePublicKey + } + + interfaceConfiguration?.addresses = base?.interface.addresses ?? [] + interfaceConfiguration?.dns = base?.interface.dns ?? [] + interfaceConfiguration?.mtu = base?.interface.mtu + + if let interfaceConfiguration = interfaceConfiguration { + self.init(name: base?.name, interface: interfaceConfiguration, peers: peerConfigurations) + } else { + throw ParseError.noInterface + } + } + + private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration { + guard let privateKeyString = attributes["private_key"] else { + throw ParseError.interfaceHasNoPrivateKey + } + guard let privateKey = PrivateKey(hexKey: privateKeyString) else { + throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString) + } + var interface = InterfaceConfiguration(privateKey: privateKey) + if let listenPortString = attributes["listen_port"] { + guard let listenPort = UInt16(listenPortString) else { + throw ParseError.interfaceHasInvalidListenPort(listenPortString) + } + if listenPort != 0 { + interface.listenPort = listenPort + } + } + return interface + } + + private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration { + guard let publicKeyString = attributes["public_key"] else { + throw ParseError.peerHasNoPublicKey + } + guard let publicKey = PublicKey(hexKey: publicKeyString) else { + throw ParseError.peerHasInvalidPublicKey(publicKeyString) + } + var peer = PeerConfiguration(publicKey: publicKey) + if let preSharedKeyString = attributes["preshared_key"] { + guard let preSharedKey = PreSharedKey(hexKey: preSharedKeyString) else { + throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString) + } + // TODO(zx2c4): does the compiler optimize this away? + var accumulator: UInt8 = 0 + for index in 0..<preSharedKey.rawValue.count { + accumulator |= preSharedKey.rawValue[index] + } + if accumulator != 0 { + peer.preSharedKey = preSharedKey + } + } + if let allowedIPsString = attributes["allowed_ip"] { + var allowedIPs = [IPAddressRange]() + for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) { + guard let allowedIP = IPAddressRange(from: allowedIPString) else { + throw ParseError.peerHasInvalidAllowedIP(allowedIPString) + } + allowedIPs.append(allowedIP) + } + peer.allowedIPs = allowedIPs + } + if let endpointString = attributes["endpoint"] { + guard let endpoint = Endpoint(from: endpointString) else { + throw ParseError.peerHasInvalidEndpoint(endpointString) + } + peer.endpoint = endpoint + } + if let persistentKeepAliveString = attributes["persistent_keepalive_interval"] { + guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else { + throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString) + } + if persistentKeepAlive != 0 { + peer.persistentKeepAlive = persistentKeepAlive + } + } + if let rxBytesString = attributes["rx_bytes"] { + guard let rxBytes = UInt64(rxBytesString) else { + throw ParseError.peerHasInvalidTransferBytes(rxBytesString) + } + if rxBytes != 0 { + peer.rxBytes = rxBytes + } + } + if let txBytesString = attributes["tx_bytes"] { + guard let txBytes = UInt64(txBytesString) else { + throw ParseError.peerHasInvalidTransferBytes(txBytesString) + } + if txBytes != 0 { + peer.txBytes = txBytes + } + } + if let lastHandshakeTimeSecString = attributes["last_handshake_time_sec"] { + var lastHandshakeTimeSince1970: TimeInterval = 0 + guard let lastHandshakeTimeSec = UInt64(lastHandshakeTimeSecString) else { + throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeSecString) + } + if lastHandshakeTimeSec != 0 { + lastHandshakeTimeSince1970 += Double(lastHandshakeTimeSec) + if let lastHandshakeTimeNsecString = attributes["last_handshake_time_nsec"] { + guard let lastHandshakeTimeNsec = UInt64(lastHandshakeTimeNsecString) else { + throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeNsecString) + } + lastHandshakeTimeSince1970 += Double(lastHandshakeTimeNsec) / 1000000000.0 + } + peer.lastHandshakeTime = Date(timeIntervalSince1970: lastHandshakeTimeSince1970) + } + } + return peer + } +} diff --git a/Sources/WireGuardApp/Tunnel/TunnelErrors.swift b/Sources/WireGuardApp/Tunnel/TunnelErrors.swift new file mode 100644 index 0000000..941ab61 --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/TunnelErrors.swift @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import NetworkExtension + +enum TunnelsManagerError: WireGuardAppError { + case tunnelNameEmpty + case tunnelAlreadyExistsWithThatName + case systemErrorOnListingTunnels(systemError: Error) + case systemErrorOnAddTunnel(systemError: Error) + case systemErrorOnModifyTunnel(systemError: Error) + case systemErrorOnRemoveTunnel(systemError: Error) + + var alertText: AlertText { + switch self { + case .tunnelNameEmpty: + return (tr("alertTunnelNameEmptyTitle"), tr("alertTunnelNameEmptyMessage")) + case .tunnelAlreadyExistsWithThatName: + return (tr("alertTunnelAlreadyExistsWithThatNameTitle"), tr("alertTunnelAlreadyExistsWithThatNameMessage")) + case .systemErrorOnListingTunnels(let systemError): + return (tr("alertSystemErrorOnListingTunnelsTitle"), systemError.localizedUIString) + case .systemErrorOnAddTunnel(let systemError): + return (tr("alertSystemErrorOnAddTunnelTitle"), systemError.localizedUIString) + case .systemErrorOnModifyTunnel(let systemError): + return (tr("alertSystemErrorOnModifyTunnelTitle"), systemError.localizedUIString) + case .systemErrorOnRemoveTunnel(let systemError): + return (tr("alertSystemErrorOnRemoveTunnelTitle"), systemError.localizedUIString) + } + } +} + +enum TunnelsManagerActivationAttemptError: WireGuardAppError { + case tunnelIsNotInactive + case failedWhileStarting(systemError: Error) // startTunnel() throwed + case failedWhileSaving(systemError: Error) // save config after re-enabling throwed + case failedWhileLoading(systemError: Error) // reloading config throwed + case failedBecauseOfTooManyErrors(lastSystemError: Error) // recursion limit reached + + var alertText: AlertText { + switch self { + case .tunnelIsNotInactive: + return (tr("alertTunnelActivationErrorTunnelIsNotInactiveTitle"), tr("alertTunnelActivationErrorTunnelIsNotInactiveMessage")) + case .failedWhileStarting(let systemError), + .failedWhileSaving(let systemError), + .failedWhileLoading(let systemError), + .failedBecauseOfTooManyErrors(let systemError): + return (tr("alertTunnelActivationSystemErrorTitle"), + tr(format: "alertTunnelActivationSystemErrorMessage (%@)", systemError.localizedUIString)) + } + } +} + +enum TunnelsManagerActivationError: WireGuardAppError { + case activationFailed(wasOnDemandEnabled: Bool) + case activationFailedWithExtensionError(title: String, message: String, wasOnDemandEnabled: Bool) + + var alertText: AlertText { + switch self { + case .activationFailed(let wasOnDemandEnabled): + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage") + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : "")) + case .activationFailedWithExtensionError(let title, let message, let wasOnDemandEnabled): + return (title, message + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : "")) + } + } +} + +extension PacketTunnelProviderError: WireGuardAppError { + var alertText: AlertText { + switch self { + case .savedProtocolConfigurationIsInvalid: + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationSavedConfigFailureMessage")) + case .dnsResolutionFailure: + return (tr("alertTunnelDNSFailureTitle"), tr("alertTunnelDNSFailureMessage")) + case .couldNotStartBackend: + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationBackendFailureMessage")) + case .couldNotDetermineFileDescriptor: + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFileDescriptorFailureMessage")) + case .couldNotSetNetworkSettings: + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationSetNetworkSettingsMessage")) + } + } +} + +extension Error { + var localizedUIString: String { + if let systemError = self as? NEVPNError { + switch systemError { + case NEVPNError.configurationInvalid: + return tr("alertSystemErrorMessageTunnelConfigurationInvalid") + case NEVPNError.configurationDisabled: + return tr("alertSystemErrorMessageTunnelConfigurationDisabled") + case NEVPNError.connectionFailed: + return tr("alertSystemErrorMessageTunnelConnectionFailed") + case NEVPNError.configurationStale: + return tr("alertSystemErrorMessageTunnelConfigurationStale") + case NEVPNError.configurationReadWriteFailed: + return tr("alertSystemErrorMessageTunnelConfigurationReadWriteFailed") + case NEVPNError.configurationUnknown: + return tr("alertSystemErrorMessageTunnelConfigurationUnknown") + default: + return "" + } + } else { + return localizedDescription + } + } +} diff --git a/Sources/WireGuardApp/Tunnel/TunnelStatus.swift b/Sources/WireGuardApp/Tunnel/TunnelStatus.swift new file mode 100644 index 0000000..547aa9f --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/TunnelStatus.swift @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import NetworkExtension + +@objc enum TunnelStatus: Int { + case inactive + case activating + case active + case deactivating + case reasserting // Not a possible state at present + case restarting // Restarting tunnel (done after saving modifications to an active tunnel) + case waiting // Waiting for another tunnel to be brought down + + init(from systemStatus: NEVPNStatus) { + switch systemStatus { + case .connected: + self = .active + case .connecting: + self = .activating + case .disconnected: + self = .inactive + case .disconnecting: + self = .deactivating + case .reasserting: + self = .reasserting + case .invalid: + self = .inactive + @unknown default: + fatalError() + } + } +} + +extension TunnelStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .inactive: return "inactive" + case .activating: return "activating" + case .active: return "active" + case .deactivating: return "deactivating" + case .reasserting: return "reasserting" + case .restarting: return "restarting" + case .waiting: return "waiting" + } + } +} + +extension NEVPNStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .connected: return "connected" + case .connecting: return "connecting" + case .disconnected: return "disconnected" + case .disconnecting: return "disconnecting" + case .reasserting: return "reasserting" + case .invalid: return "invalid" + @unknown default: + fatalError() + } + } +} diff --git a/Sources/WireGuardApp/Tunnel/TunnelsManager.swift b/Sources/WireGuardApp/Tunnel/TunnelsManager.swift new file mode 100644 index 0000000..af6a9bb --- /dev/null +++ b/Sources/WireGuardApp/Tunnel/TunnelsManager.swift @@ -0,0 +1,807 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import NetworkExtension +import os.log +import WireGuardKit + +protocol TunnelsManagerListDelegate: class { + func tunnelAdded(at index: Int) + func tunnelModified(at index: Int) + func tunnelMoved(from oldIndex: Int, to newIndex: Int) + func tunnelRemoved(at index: Int, tunnel: TunnelContainer) +} + +protocol TunnelsManagerActivationDelegate: class { + func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) // startTunnel wasn't called or failed + func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) // startTunnel succeeded + func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) // status didn't change to connected + func tunnelActivationSucceeded(tunnel: TunnelContainer) // status changed to connected +} + +class TunnelsManager { + fileprivate var tunnels: [TunnelContainer] + weak var tunnelsListDelegate: TunnelsManagerListDelegate? + weak var activationDelegate: TunnelsManagerActivationDelegate? + private var statusObservationToken: AnyObject? + private var waiteeObservationToken: AnyObject? + private var configurationsObservationToken: AnyObject? + private var catalinaWorkaround: Any? + + init(tunnelProviders: [NETunnelProviderManager]) { + tunnels = tunnelProviders.map { TunnelContainer(tunnel: $0) }.sorted { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) } + startObservingTunnelStatuses() + startObservingTunnelConfigurations() + #if os(macOS) + if #available(macOS 10.15, *) { + self.catalinaWorkaround = CatalinaWorkaround(tunnelsManager: self) + } + #endif + } + + static func create(completionHandler: @escaping (Result<TunnelsManager, TunnelsManagerError>) -> Void) { + #if targetEnvironment(simulator) + completionHandler(.success(TunnelsManager(tunnelProviders: MockTunnels.createMockTunnels()))) + #else + NETunnelProviderManager.loadAllFromPreferences { managers, error in + if let error = error { + wg_log(.error, message: "Failed to load tunnel provider managers: \(error)") + completionHandler(.failure(TunnelsManagerError.systemErrorOnListingTunnels(systemError: error))) + return + } + + var tunnelManagers = managers ?? [] + var refs: Set<Data> = [] + var tunnelNames: Set<String> = [] + for (index, tunnelManager) in tunnelManagers.enumerated().reversed() { + if let tunnelName = tunnelManager.localizedDescription { + tunnelNames.insert(tunnelName) + } + guard let proto = tunnelManager.protocolConfiguration as? NETunnelProviderProtocol else { continue } + if proto.migrateConfigurationIfNeeded(called: tunnelManager.localizedDescription ?? "unknown") { + tunnelManager.saveToPreferences { _ in } + } + #if os(iOS) + let passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil + #elseif os(macOS) + let passwordRef: Data? + if proto.providerConfiguration?["UID"] as? uid_t == getuid() { + passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil + } else { + passwordRef = proto.passwordReference // To handle multiple users in macOS, we skip verifying + } + #else + #error("Unimplemented") + #endif + if let ref = passwordRef { + refs.insert(ref) + } else { + wg_log(.info, message: "Removing orphaned tunnel with non-verifying keychain entry: \(tunnelManager.localizedDescription ?? "<unknown>")") + tunnelManager.removeFromPreferences { _ in } + tunnelManagers.remove(at: index) + } + } + #if os(macOS) + if #available(macOS 10.15, *) { + // Don't delete orphaned keychain refs. We need them to restore tunnels as a workaround. + } else { + Keychain.deleteReferences(except: refs) + } + #else + Keychain.deleteReferences(except: refs) + #endif + #if os(iOS) + RecentTunnelsTracker.cleanupTunnels(except: tunnelNames) + #endif + completionHandler(.success(TunnelsManager(tunnelProviders: tunnelManagers))) + } + #endif + } + + func reload() { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, _ in + guard let self = self else { return } + + let loadedTunnelProviders = managers ?? [] + + for (index, currentTunnel) in self.tunnels.enumerated().reversed() { + if !loadedTunnelProviders.contains(where: { $0.isEquivalentTo(currentTunnel) }) { + // Tunnel was deleted outside the app + self.tunnels.remove(at: index) + self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: currentTunnel) + } + } + for loadedTunnelProvider in loadedTunnelProviders { + if let matchingTunnel = self.tunnels.first(where: { loadedTunnelProvider.isEquivalentTo($0) }) { + matchingTunnel.tunnelProvider = loadedTunnelProvider + matchingTunnel.refreshStatus() + } else { + // Tunnel was added outside the app + if let proto = loadedTunnelProvider.protocolConfiguration as? NETunnelProviderProtocol { + if proto.migrateConfigurationIfNeeded(called: loadedTunnelProvider.localizedDescription ?? "unknown") { + loadedTunnelProvider.saveToPreferences { _ in } + } + } + let tunnel = TunnelContainer(tunnel: loadedTunnelProvider) + self.tunnels.append(tunnel) + self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) } + self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!) + } + } + } + } + + func add(tunnelConfiguration: TunnelConfiguration, onDemandOption: ActivateOnDemandOption = .off, completionHandler: @escaping (Result<TunnelContainer, TunnelsManagerError>) -> Void) { + let tunnelName = tunnelConfiguration.name ?? "" + if tunnelName.isEmpty { + completionHandler(.failure(TunnelsManagerError.tunnelNameEmpty)) + return + } + + if tunnels.contains(where: { $0.name == tunnelName }) { + completionHandler(.failure(TunnelsManagerError.tunnelAlreadyExistsWithThatName)) + return + } + + let tunnelProviderManager = NETunnelProviderManager() + tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration) + tunnelProviderManager.isEnabled = true + + onDemandOption.apply(on: tunnelProviderManager) + + let activeTunnel = tunnels.first { $0.status == .active || $0.status == .activating } + + tunnelProviderManager.saveToPreferences { [weak self] error in + guard error == nil else { + wg_log(.error, message: "Add: Saving configuration failed: \(error!)") + (tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference() + completionHandler(.failure(TunnelsManagerError.systemErrorOnAddTunnel(systemError: error!))) + return + } + + guard let self = self else { return } + + #if os(iOS) + // HACK: In iOS, adding a tunnel causes deactivation of any currently active tunnel. + // This is an ugly hack to reactivate the tunnel that has been deactivated like that. + if let activeTunnel = activeTunnel { + if activeTunnel.status == .inactive || activeTunnel.status == .deactivating { + self.startActivation(of: activeTunnel) + } + if activeTunnel.status == .active || activeTunnel.status == .activating { + activeTunnel.status = .restarting + } + } + #endif + + let tunnel = TunnelContainer(tunnel: tunnelProviderManager) + self.tunnels.append(tunnel) + self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) } + self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!) + completionHandler(.success(tunnel)) + } + } + + func addMultiple(tunnelConfigurations: [TunnelConfiguration], completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) { + addMultiple(tunnelConfigurations: ArraySlice(tunnelConfigurations), numberSuccessful: 0, lastError: nil, completionHandler: completionHandler) + } + + private func addMultiple(tunnelConfigurations: ArraySlice<TunnelConfiguration>, numberSuccessful: UInt, lastError: TunnelsManagerError?, completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) { + guard let head = tunnelConfigurations.first else { + completionHandler(numberSuccessful, lastError) + return + } + let tail = tunnelConfigurations.dropFirst() + add(tunnelConfiguration: head) { [weak self, tail] result in + DispatchQueue.main.async { + var numberSuccessfulCount = numberSuccessful + var lastError: TunnelsManagerError? + switch result { + case .failure(let error): + lastError = error + case .success: + numberSuccessfulCount = numberSuccessful + 1 + } + self?.addMultiple(tunnelConfigurations: tail, numberSuccessful: numberSuccessfulCount, lastError: lastError, completionHandler: completionHandler) + } + } + } + + func modify(tunnel: TunnelContainer, tunnelConfiguration: TunnelConfiguration, onDemandOption: ActivateOnDemandOption, completionHandler: @escaping (TunnelsManagerError?) -> Void) { + let tunnelName = tunnelConfiguration.name ?? "" + if tunnelName.isEmpty { + completionHandler(TunnelsManagerError.tunnelNameEmpty) + return + } + + let tunnelProviderManager = tunnel.tunnelProvider + let oldName = tunnelProviderManager.localizedDescription ?? "" + let isNameChanged = tunnelName != oldName + if isNameChanged { + guard !tunnels.contains(where: { $0.name == tunnelName }) else { + completionHandler(TunnelsManagerError.tunnelAlreadyExistsWithThatName) + return + } + tunnel.name = tunnelName + } + + var isTunnelConfigurationChanged = false + if tunnelProviderManager.tunnelConfiguration != tunnelConfiguration { + tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration) + isTunnelConfigurationChanged = true + } + tunnelProviderManager.isEnabled = true + + let isActivatingOnDemand = !tunnelProviderManager.isOnDemandEnabled && onDemandOption != .off + onDemandOption.apply(on: tunnelProviderManager) + + tunnelProviderManager.saveToPreferences { [weak self] error in + guard error == nil else { + //TODO: the passwordReference for the old one has already been removed at this point and we can't easily roll back! + wg_log(.error, message: "Modify: Saving configuration failed: \(error!)") + completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error!)) + return + } + guard let self = self else { return } + if isNameChanged { + let oldIndex = self.tunnels.firstIndex(of: tunnel)! + self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) } + let newIndex = self.tunnels.firstIndex(of: tunnel)! + self.tunnelsListDelegate?.tunnelMoved(from: oldIndex, to: newIndex) + #if os(iOS) + RecentTunnelsTracker.handleTunnelRenamed(oldName: oldName, newName: tunnelName) + #endif + } + self.tunnelsListDelegate?.tunnelModified(at: self.tunnels.firstIndex(of: tunnel)!) + + if isTunnelConfigurationChanged { + if tunnel.status == .active || tunnel.status == .activating || tunnel.status == .reasserting { + // Turn off the tunnel, and then turn it back on, so the changes are made effective + tunnel.status = .restarting + (tunnel.tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel() + } + } + + if isActivatingOnDemand { + // Reload tunnel after saving. + // Without this, the tunnel stopes getting updates on the tunnel status from iOS. + tunnelProviderManager.loadFromPreferences { error in + tunnel.isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled + guard error == nil else { + wg_log(.error, message: "Modify: Re-loading after saving configuration failed: \(error!)") + completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error!)) + return + } + completionHandler(nil) + } + } else { + completionHandler(nil) + } + } + } + + func remove(tunnel: TunnelContainer, completionHandler: @escaping (TunnelsManagerError?) -> Void) { + let tunnelProviderManager = tunnel.tunnelProvider + #if os(macOS) + if tunnel.isTunnelAvailableToUser { + (tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference() + } + #elseif os(iOS) + (tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference() + #else + #error("Unimplemented") + #endif + tunnelProviderManager.removeFromPreferences { [weak self] error in + guard error == nil else { + wg_log(.error, message: "Remove: Saving configuration failed: \(error!)") + completionHandler(TunnelsManagerError.systemErrorOnRemoveTunnel(systemError: error!)) + return + } + if let self = self, let index = self.tunnels.firstIndex(of: tunnel) { + self.tunnels.remove(at: index) + self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: tunnel) + } + completionHandler(nil) + + #if os(iOS) + RecentTunnelsTracker.handleTunnelRemoved(tunnelName: tunnel.name) + #endif + } + } + + func removeMultiple(tunnels: [TunnelContainer], completionHandler: @escaping (TunnelsManagerError?) -> Void) { + removeMultiple(tunnels: ArraySlice(tunnels), completionHandler: completionHandler) + } + + private func removeMultiple(tunnels: ArraySlice<TunnelContainer>, completionHandler: @escaping (TunnelsManagerError?) -> Void) { + guard let head = tunnels.first else { + completionHandler(nil) + return + } + let tail = tunnels.dropFirst() + remove(tunnel: head) { [weak self, tail] error in + DispatchQueue.main.async { + if let error = error { + completionHandler(error) + } else { + self?.removeMultiple(tunnels: tail, completionHandler: completionHandler) + } + } + } + } + + func numberOfTunnels() -> Int { + return tunnels.count + } + + func tunnel(at index: Int) -> TunnelContainer { + return tunnels[index] + } + + func mapTunnels<T>(transform: (TunnelContainer) throws -> T) rethrows -> [T] { + return try tunnels.map(transform) + } + + func index(of tunnel: TunnelContainer) -> Int? { + return tunnels.firstIndex(of: tunnel) + } + + func tunnel(named tunnelName: String) -> TunnelContainer? { + return tunnels.first { $0.name == tunnelName } + } + + func waitingTunnel() -> TunnelContainer? { + return tunnels.first { $0.status == .waiting } + } + + func tunnelInOperation() -> TunnelContainer? { + if let waitingTunnelObject = waitingTunnel() { + return waitingTunnelObject + } + return tunnels.first { $0.status != .inactive } + } + + func startActivation(of tunnel: TunnelContainer) { + guard tunnels.contains(tunnel) else { return } // Ensure it's not deleted + guard tunnel.status == .inactive else { + activationDelegate?.tunnelActivationAttemptFailed(tunnel: tunnel, error: .tunnelIsNotInactive) + return + } + + if let alreadyWaitingTunnel = tunnels.first(where: { $0.status == .waiting }) { + alreadyWaitingTunnel.status = .inactive + } + + if let tunnelInOperation = tunnels.first(where: { $0.status != .inactive }) { + wg_log(.info, message: "Tunnel '\(tunnel.name)' waiting for deactivation of '\(tunnelInOperation.name)'") + tunnel.status = .waiting + activateWaitingTunnelOnDeactivation(of: tunnelInOperation) + if tunnelInOperation.status != .deactivating { + startDeactivation(of: tunnelInOperation) + } + return + } + + #if targetEnvironment(simulator) + tunnel.status = .active + #else + tunnel.startActivation(activationDelegate: activationDelegate) + #endif + + #if os(iOS) + RecentTunnelsTracker.handleTunnelActivated(tunnelName: tunnel.name) + #endif + } + + func startDeactivation(of tunnel: TunnelContainer) { + tunnel.isAttemptingActivation = false + guard tunnel.status != .inactive && tunnel.status != .deactivating else { return } + #if targetEnvironment(simulator) + tunnel.status = .inactive + #else + tunnel.startDeactivation() + #endif + } + + func refreshStatuses() { + tunnels.forEach { $0.refreshStatus() } + } + + private func activateWaitingTunnelOnDeactivation(of tunnel: TunnelContainer) { + waiteeObservationToken = tunnel.observe(\.status) { [weak self] tunnel, _ in + guard let self = self else { return } + if tunnel.status == .inactive { + if let waitingTunnel = self.tunnels.first(where: { $0.status == .waiting }) { + waitingTunnel.startActivation(activationDelegate: self.activationDelegate) + } + self.waiteeObservationToken = nil + } + } + } + + private func startObservingTunnelStatuses() { + statusObservationToken = NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: OperationQueue.main) { [weak self] statusChangeNotification in + guard let self = self, + let session = statusChangeNotification.object as? NETunnelProviderSession, + let tunnelProvider = session.manager as? NETunnelProviderManager, + let tunnel = self.tunnels.first(where: { $0.tunnelProvider == tunnelProvider }) else { return } + + wg_log(.debug, message: "Tunnel '\(tunnel.name)' connection status changed to '\(tunnel.tunnelProvider.connection.status)'") + + if tunnel.isAttemptingActivation { + if session.status == .connected { + tunnel.isAttemptingActivation = false + self.activationDelegate?.tunnelActivationSucceeded(tunnel: tunnel) + } else if session.status == .disconnected { + tunnel.isAttemptingActivation = false + if let (title, message) = lastErrorTextFromNetworkExtension(for: tunnel) { + self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailedWithExtensionError(title: title, message: message, wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled)) + } else { + self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailed(wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled)) + } + } + } + + if tunnel.status == .restarting && session.status == .disconnected { + tunnel.startActivation(activationDelegate: self.activationDelegate) + return + } + + tunnel.refreshStatus() + } + } + + func startObservingTunnelConfigurations() { + configurationsObservationToken = NotificationCenter.default.addObserver(forName: .NEVPNConfigurationChange, object: nil, queue: OperationQueue.main) { [weak self] _ in + DispatchQueue.main.async { [weak self] in + // We schedule reload() in a subsequent runloop to ensure that the completion handler of loadAllFromPreferences + // (reload() calls loadAllFromPreferences) is called after the completion handler of the saveToPreferences or + // removeFromPreferences call, if any, that caused this notification to fire. This notification can also fire + // as a result of a tunnel getting added or removed outside of the app. + self?.reload() + } + } + } + + static func tunnelNameIsLessThan(_ a: String, _ b: String) -> Bool { + return a.compare(b, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive, .numeric]) == .orderedAscending + } +} + +private func lastErrorTextFromNetworkExtension(for tunnel: TunnelContainer) -> (title: String, message: String)? { + guard let lastErrorFileURL = FileManager.networkExtensionLastErrorFileURL else { return nil } + guard let lastErrorData = try? Data(contentsOf: lastErrorFileURL) else { return nil } + guard let lastErrorStrings = String(data: lastErrorData, encoding: .utf8)?.splitToArray(separator: "\n") else { return nil } + guard lastErrorStrings.count == 2 && tunnel.activationAttemptId == lastErrorStrings[0] else { return nil } + + if let extensionError = PacketTunnelProviderError(rawValue: lastErrorStrings[1]) { + return extensionError.alertText + } + + return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage")) +} + +class TunnelContainer: NSObject { + @objc dynamic var name: String + @objc dynamic var status: TunnelStatus + + @objc dynamic var isActivateOnDemandEnabled: Bool + + var isAttemptingActivation = false { + didSet { + if isAttemptingActivation { + self.activationTimer?.invalidate() + let activationTimer = Timer(timeInterval: 5 /* seconds */, repeats: true) { [weak self] _ in + guard let self = self else { return } + wg_log(.debug, message: "Status update notification timeout for tunnel '\(self.name)'. Tunnel status is now '\(self.tunnelProvider.connection.status)'.") + switch self.tunnelProvider.connection.status { + case .connected, .disconnected, .invalid: + self.activationTimer?.invalidate() + self.activationTimer = nil + default: + break + } + self.refreshStatus() + } + self.activationTimer = activationTimer + RunLoop.main.add(activationTimer, forMode: .common) + } + } + } + var activationAttemptId: String? + var activationTimer: Timer? + var deactivationTimer: Timer? + + fileprivate var tunnelProvider: NETunnelProviderManager + + var tunnelConfiguration: TunnelConfiguration? { + return tunnelProvider.tunnelConfiguration + } + + var onDemandOption: ActivateOnDemandOption { + return ActivateOnDemandOption(from: tunnelProvider) + } + + #if os(macOS) + var isTunnelAvailableToUser: Bool { + return (tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration?["UID"] as? uid_t == getuid() + } + #endif + + init(tunnel: NETunnelProviderManager) { + name = tunnel.localizedDescription ?? "Unnamed" + let status = TunnelStatus(from: tunnel.connection.status) + self.status = status + isActivateOnDemandEnabled = tunnel.isOnDemandEnabled + tunnelProvider = tunnel + super.init() + } + + func getRuntimeTunnelConfiguration(completionHandler: @escaping ((TunnelConfiguration?) -> Void)) { + guard status != .inactive, let session = tunnelProvider.connection as? NETunnelProviderSession else { + completionHandler(tunnelConfiguration) + return + } + guard nil != (try? session.sendProviderMessage(Data([ UInt8(0) ]), responseHandler: { + guard self.status != .inactive, let data = $0, let base = self.tunnelConfiguration, let settings = String(data: data, encoding: .utf8) else { + completionHandler(self.tunnelConfiguration) + return + } + completionHandler((try? TunnelConfiguration(fromUapiConfig: settings, basedOn: base)) ?? self.tunnelConfiguration) + })) else { + completionHandler(tunnelConfiguration) + return + } + } + + func refreshStatus() { + if status == .restarting { + return + } + status = TunnelStatus(from: tunnelProvider.connection.status) + isActivateOnDemandEnabled = tunnelProvider.isOnDemandEnabled + } + + fileprivate func startActivation(recursionCount: UInt = 0, lastError: Error? = nil, activationDelegate: TunnelsManagerActivationDelegate?) { + if recursionCount >= 8 { + wg_log(.error, message: "startActivation: Failed after 8 attempts. Giving up with \(lastError!)") + activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedBecauseOfTooManyErrors(lastSystemError: lastError!)) + return + } + + wg_log(.debug, message: "startActivation: Entering (tunnel: \(name))") + + status = .activating // Ensure that no other tunnel can attempt activation until this tunnel is done trying + + guard tunnelProvider.isEnabled else { + // In case the tunnel had gotten disabled, re-enable and save it, + // then call this function again. + wg_log(.debug, staticMessage: "startActivation: Tunnel is disabled. Re-enabling and saving") + tunnelProvider.isEnabled = true + tunnelProvider.saveToPreferences { [weak self] error in + guard let self = self else { return } + if error != nil { + wg_log(.error, message: "Error saving tunnel after re-enabling: \(error!)") + activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileSaving(systemError: error!)) + return + } + wg_log(.debug, staticMessage: "startActivation: Tunnel saved after re-enabling, invoking startActivation") + self.startActivation(recursionCount: recursionCount + 1, lastError: NEVPNError(NEVPNError.configurationUnknown), activationDelegate: activationDelegate) + } + return + } + + // Start the tunnel + do { + wg_log(.debug, staticMessage: "startActivation: Starting tunnel") + isAttemptingActivation = true + let activationAttemptId = UUID().uuidString + self.activationAttemptId = activationAttemptId + try (tunnelProvider.connection as? NETunnelProviderSession)?.startTunnel(options: ["activationAttemptId": activationAttemptId]) + wg_log(.debug, staticMessage: "startActivation: Success") + activationDelegate?.tunnelActivationAttemptSucceeded(tunnel: self) + } catch let error { + isAttemptingActivation = false + guard let systemError = error as? NEVPNError else { + wg_log(.error, message: "Failed to activate tunnel: Error: \(error)") + status = .inactive + activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: error)) + return + } + guard systemError.code == NEVPNError.configurationInvalid || systemError.code == NEVPNError.configurationStale else { + wg_log(.error, message: "Failed to activate tunnel: VPN Error: \(error)") + status = .inactive + activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: systemError)) + return + } + wg_log(.debug, staticMessage: "startActivation: Will reload tunnel and then try to start it.") + tunnelProvider.loadFromPreferences { [weak self] error in + guard let self = self else { return } + if error != nil { + wg_log(.error, message: "startActivation: Error reloading tunnel: \(error!)") + self.status = .inactive + activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileLoading(systemError: systemError)) + return + } + wg_log(.debug, staticMessage: "startActivation: Tunnel reloaded, invoking startActivation") + self.startActivation(recursionCount: recursionCount + 1, lastError: systemError, activationDelegate: activationDelegate) + } + } + } + + fileprivate func startDeactivation() { + wg_log(.debug, message: "startDeactivation: Tunnel: \(name)") + (tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel() + } +} + +extension NETunnelProviderManager { + fileprivate static var cachedConfigKey: UInt8 = 0 + + var tunnelConfiguration: TunnelConfiguration? { + if let cached = objc_getAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey) as? TunnelConfiguration { + return cached + } + let config = (protocolConfiguration as? NETunnelProviderProtocol)?.asTunnelConfiguration(called: localizedDescription) + if config != nil { + objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, config, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + return config + } + + func setTunnelConfiguration(_ tunnelConfiguration: TunnelConfiguration) { + protocolConfiguration = NETunnelProviderProtocol(tunnelConfiguration: tunnelConfiguration, previouslyFrom: protocolConfiguration) + localizedDescription = tunnelConfiguration.name + objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, tunnelConfiguration, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + func isEquivalentTo(_ tunnel: TunnelContainer) -> Bool { + return localizedDescription == tunnel.name && tunnelConfiguration == tunnel.tunnelConfiguration + } +} + +#if os(macOS) +@available(macOS 10.15, *) +class CatalinaWorkaround { + + // In macOS Catalina, for some users, the tunnels get deleted arbitrarily + // by the OS. It's not clear what triggers that. + + // As a workaround, in macOS Catalina, when we realize that tunnels have been + // deleted outside the app, we reinstate those tunnels using the information + // in the keychain. + + unowned let tunnelsManager: TunnelsManager + private var configChangeSubscriber: Any? + + struct ReinstationData { + let tunnelConfiguration: TunnelConfiguration + let keychainPasswordRef: Data + } + + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + + // Attempt reinstation when there's a change in tunnel configurations, + // which indicates that tunnels may have been deleted outside the app. + // We use debounce to wait for all change notifications to arrive + // before attempting to reinstate, so that we don't have saveToPreferences + // being called while another saveToPreferences is in progress. + self.configChangeSubscriber = NotificationCenter.default + .publisher(for: .NEVPNConfigurationChange, object: nil) + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .subscribe(on: RunLoop.main) + .sink { [weak self] _ in + self?.reinstateTunnelsDeletedOutsideApp() + } + + // Attempt reinstation on app launch + reinstateTunnelsDeletedOutsideApp() + } + + func reinstateTunnelsDeletedOutsideApp() { + let rd = reinstationDataForTunnelsDeletedOutsideApp() + reinstateTunnels(ArraySlice(rd), completionHandler: nil) + } + + private func reinstateTunnels(_ rdArray: ArraySlice<ReinstationData>, completionHandler: (() -> Void)?) { + guard let head = rdArray.first else { + completionHandler?() + return + } + let tail = rdArray.dropFirst() + self.tunnelsManager.reinstateTunnel(reinstationData: head) { _ in + DispatchQueue.main.async { + self.reinstateTunnels(tail, completionHandler: completionHandler) + } + } + } + + private func reinstationDataForTunnelsDeletedOutsideApp() -> [ReinstationData] { + let knownRefs: [Data] = self.tunnelsManager.tunnels + .compactMap { $0.tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol } + .compactMap { $0.passwordReference } + let knownRefsSet: Set<Data> = Set(knownRefs) + var result: CFTypeRef? + let ret = SecItemCopyMatching([kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Bundle.main.bundleIdentifier as Any, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnPersistentRef as String: true] as CFDictionary, + &result) + guard ret == errSecSuccess, let resultDicts = result as? [[String: Any]] else { return [] } + let labelPrefix = "WireGuard Tunnel: " + var reinstationData: [ReinstationData] = [] + for resultDict in resultDicts { + guard let ref = resultDict[kSecValuePersistentRef as String] as? Data else { continue } + guard let label = resultDict[kSecAttrLabel as String] as? String else { continue } + guard label.hasPrefix(labelPrefix) else { continue } + if !knownRefsSet.contains(ref) { + let tunnelName = String(label.dropFirst(labelPrefix.count)) + if let configStr = Keychain.openReference(called: ref), + let config = try? TunnelConfiguration(fromWgQuickConfig: configStr, called: tunnelName) { + reinstationData.append(ReinstationData(tunnelConfiguration: config, keychainPasswordRef: ref)) + } + } + } + return reinstationData + } +} +#endif + +#if os(macOS) +@available(macOS 10.15, *) +extension TunnelsManager { + fileprivate func reinstateTunnel(reinstationData: CatalinaWorkaround.ReinstationData, completionHandler: @escaping (Bool) -> Void) { + let tunnelName = reinstationData.tunnelConfiguration.name ?? "" + if tunnelName.isEmpty { + completionHandler(false) + return + } + + if tunnels.contains(where: { $0.name == tunnelName }) { + completionHandler(false) + return + } + + let tunnelProviderProtocol = NETunnelProviderProtocol() + guard let appId = Bundle.main.bundleIdentifier else { fatalError() } + tunnelProviderProtocol.providerBundleIdentifier = "\(appId).network-extension" + tunnelProviderProtocol.passwordReference = reinstationData.keychainPasswordRef + tunnelProviderProtocol.providerConfiguration = ["UID": getuid()] + tunnelProviderProtocol.serverAddress = { + let endpoints = reinstationData.tunnelConfiguration.peers.compactMap { $0.endpoint } + if endpoints.count == 1 { + return endpoints[0].stringRepresentation + } else if endpoints.isEmpty { + return "Unspecified" + } else { + return "Multiple endpoints" + } + }() + + let tunnelProvider = NETunnelProviderManager() + tunnelProvider.localizedDescription = tunnelName + tunnelProvider.protocolConfiguration = tunnelProviderProtocol + objc_setAssociatedObject(tunnelProvider, &NETunnelProviderManager.cachedConfigKey, reinstationData.tunnelConfiguration, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + tunnelProvider.isEnabled = true + + tunnelProvider.saveToPreferences { [weak self] error in + guard error == nil else { + wg_log(.error, message: "Reinstate: Saving configuration failed: \(error!)") + completionHandler(false) + return + } + + guard let self = self else { return } + + let tunnel = TunnelContainer(tunnel: tunnelProvider) + self.tunnels.append(tunnel) + self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) } + self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!) + completionHandler(true) + } + } +} +#endif |