From e591b92ec9a7a48973a238aa550599f216ffbf5f Mon Sep 17 00:00:00 2001 From: Roopesh Chander Date: Mon, 7 Aug 2023 12:20:05 +0530 Subject: [PATCH] apple: Set network settings using data from connlib (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR sets the network settings, split-DNS, and macOS UI resources using the data from connlib callbacks. This should enable connlib to be developed / tested in Apple platforms (Caveat: There's no UI to see resources in iOS yet). Some assumptions being made are: - It's ok to call disconnect() before onTunnelReady(), but after connect() - CIDR addresses don't include enclosing quotes (they currently include the quotes, like: `"8.8.4.4/32"`) - CIDR addresses in routes always end with ā€œ/nā€ - Connlib calls can be made from a queue (non-main thread) --------- Co-authored-by: Jamil --- .../Sources/Connlib/CallbackHandler.swift | 5 +- .../apple/Firezone.xcodeproj/project.pbxproj | 6 + .../FirezoneNetworkExtension/Adapter.swift | 505 +++++++++++------- .../NetworkResource.swift | 7 + .../NetworkSettings.swift | 194 +++++++ .../PacketTunnelProvider.swift | 32 +- 6 files changed, 523 insertions(+), 226 deletions(-) create mode 100644 swift/apple/FirezoneNetworkExtension/NetworkSettings.swift diff --git a/rust/connlib/clients/apple/Sources/Connlib/CallbackHandler.swift b/rust/connlib/clients/apple/Sources/Connlib/CallbackHandler.swift index 60ceabae8..c626c2872 100644 --- a/rust/connlib/clients/apple/Sources/Connlib/CallbackHandler.swift +++ b/rust/connlib/clients/apple/Sources/Connlib/CallbackHandler.swift @@ -16,7 +16,7 @@ extension RustString: @unchecked Sendable {} extension RustString: Error {} public protocol CallbackHandlerDelegate: AnyObject { - func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String) + func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String) func onTunnelReady() func onAddRoute(_: String) func onRemoveRoute(_: String) @@ -34,7 +34,8 @@ public class CallbackHandler { delegate?.onSetInterfaceConfig( tunnelAddressIPv4: tunnelAddressIPv4.toString(), tunnelAddressIPv6: tunnelAddressIPv6.toString(), - dnsAddress: dnsAddress.toString() + dnsAddress: dnsAddress.toString(), + dnsFallbackStrategy: "system_resolver" // Will come from a onSetInterfaceConfig arg eventually ) } diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index 544d4b937..1a607a9d5 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; }; 6FE4550F2A5D112C006549B1 /* connlib-apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* connlib-apple.swift */; }; 6FE455102A5D112C006549B1 /* connlib-apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* connlib-apple.swift */; }; + 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; + 6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; 794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; }; 794C38172970A26A0029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38162970A26A0029F38F /* FirezoneKit */; }; 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; }; @@ -105,6 +107,7 @@ 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = ""; }; 6FE4550E2A5D112C006549B1 /* connlib-apple.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "connlib-apple.swift"; path = "Connlib/Generated/connlib-apple/connlib-apple.swift"; sourceTree = ""; }; 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = ""; }; + 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = ""; }; 8DCC021928D512AC007E12D2 /* Firezone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Firezone.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8DCC021C28D512AC007E12D2 /* FirezoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirezoneApp.swift; sourceTree = ""; }; 8DCC022528D512AE007E12D2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -159,6 +162,7 @@ 05CF1CDE290B1A9000CF4755 /* FirezoneNetworkExtension_macOS.entitlements */, 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, + 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, 6FA39A032A6A7248000F0157 /* NetworkResource.swift */, 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, @@ -456,6 +460,7 @@ 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */, 6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */, 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, + 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -469,6 +474,7 @@ 6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */, 6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */, 6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, + 6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 3c1a2e26c..37a0c77a0 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -12,30 +12,55 @@ public enum AdapterError: Error { /// Failure to perform an operation in such state. case invalidState + /// connlib failed to start + case connlibConnectError(Error) + + /// connlib fatal error + case connlibFatalError(String) + + /// No network settings were provided + case noNetworkSettings + /// Failure to set network settings. case setNetworkSettings(Error) + + /// stop() called before the tunnel became ready + case stoppedByRequestWhileStarting } -/// Enum representing internal state of the `WireGuardAdapter` -private enum State { - /// The tunnel is stopped - case stopped +/// Enum representing internal state of the adapter +private enum AdapterState: CustomStringConvertible { + case startingTunnel(session: WrappedSession, onStarted: Adapter.StartTunnelCompletionHandler?) + case tunnelReady(session: WrappedSession) + case stoppingTunnel(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?) + case stoppedTunnel + case stoppingTunnelTemporarily(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?) + case stoppedTunnelTemporarily - /// The tunnel is up and running - case started(_ handle: WrappedSession) - - /// The tunnel is temporarily shutdown due to device going offline - case temporaryShutdown + var description: String { + switch self { + case .startingTunnel: return "startingTunnel" + case .tunnelReady: return "tunnelReady" + case .stoppingTunnel: return "stoppingTunnel" + case .stoppedTunnel: return "stoppedTunnel" + case .stoppingTunnelTemporarily: return "stoppingTunnelTemporarily" + case .stoppedTunnelTemporarily: return "stoppedTunnelTemporarily" + } + } } // Loosely inspired from WireGuardAdapter from WireGuardKit public class Adapter { + + typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void) + typealias StopTunnelCompletionHandler = (() -> Void) + private let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel") private var callbackHandler: CallbackHandler - // Latest applied NETunnelProviderNetworkSettings - public var lastNetworkSettings: NEPacketTunnelNetworkSettings? + /// Network settings + private var networkSettings: NetworkSettings? /// Packet tunnel provider. private weak var packetTunnelProvider: NEPacketTunnelProvider? @@ -47,25 +72,36 @@ public class Adapter { private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue") /// Adapter state. - private var state: State = .stopped + private var state: AdapterState { + didSet { + logger.debug("Adapter state changed to: \(self.state, privacy: .public)") + } + } /// Keep track of resources private var displayableResources = DisplayableResources() - public init(with packetTunnelProvider: NEPacketTunnelProvider) { + /// Starting parameters + private var portalURLString: String + private var token: String + + public init(portalURLString: String, token: String, packetTunnelProvider: NEPacketTunnelProvider) { + self.portalURLString = portalURLString + self.token = token self.packetTunnelProvider = packetTunnelProvider self.callbackHandler = CallbackHandler() - self.callbackHandler.delegate = self + self.state = .stoppedTunnel } deinit { + self.logger.debug("Adapter.deinit") // Cancel network monitor networkMonitor?.cancel() // Shutdown the tunnel - if case .started(let wrappedSession) = self.state { - self.logger.log(level: .debug, "\(#function)") - wrappedSession.disconnect() + if case .tunnelReady(let wrappedSession) = self.state { + logger.debug("Adapter.deinit: Shutting down connlib") + _ = wrappedSession.disconnect() } } @@ -73,59 +109,76 @@ public class Adapter { /// - Parameters: /// - completionHandler: completion handler. public func start(completionHandler: @escaping (AdapterError?) -> Void) throws { - workQueue.async { - guard case .stopped = self.state else { + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.start") + guard case .stoppedTunnel = self.state else { completionHandler(.invalidState) return } - let networkMonitor = NWPathMonitor() - networkMonitor.pathUpdateHandler = { [weak self] path in - self?.didReceivePathUpdate(path: path) - } - networkMonitor.start(queue: self.workQueue) + self.callbackHandler.delegate = self + self.logger.debug("Adapter.start: Starting connlib") do { - try self.setNetworkSettings(self.generateNetworkSettings(ipv4Routes: [], ipv6Routes: [])) - self.state = .started( - try WrappedSession.connect("http://localhost:4568", "test-token", self.callbackHandler) + self.state = .startingTunnel( + session: try WrappedSession.connect(self.portalURLString, self.token, self.callbackHandler), + onStarted: completionHandler ) - self.networkMonitor = networkMonitor - completionHandler(nil) - } catch let error as AdapterError { - networkMonitor.cancel() - completionHandler(error) - } catch { - fatalError() + } catch let error { + self.logger.error("Adapter.start: Error: \(error, privacy: .public)") + self.state = .stoppedTunnel + completionHandler(AdapterError.connlibConnectError(error)) } + } } - public func stop(completionHandler: @escaping (AdapterError?) -> Void) { - workQueue.async { - switch self.state { - case .started(let wrappedSession): - wrappedSession.disconnect() - case .temporaryShutdown: - break + /// Stop the tunnel + public func stop(completionHandler: @escaping () -> Void) { + workQueue.async { [weak self] in + guard let self = self else { return } - case .stopped: - completionHandler(.invalidState) - return + self.logger.debug("Adapter.stop") + + switch self.state { + case .stoppedTunnel, .stoppingTunnel: + break + case .tunnelReady(let session): + self.logger.debug("Adapter.stop: Shutting down connlib") + self.state = .stoppingTunnel(session: session, onStopped: completionHandler) + _ = session.disconnect() + case .startingTunnel(let session, let onStarted): + self.logger.debug("Adapter.stop: Shutting down connlib before tunnel ready") + self.state = .stoppingTunnel(session: session, onStopped: { + onStarted?(AdapterError.stoppedByRequestWhileStarting) + completionHandler() + }) + // FIXME: Is it ok to call disconnect() while we haven't got onTunnelReady? + _ = session.disconnect() + case .stoppingTunnelTemporarily(let session, let onStopped): + self.state = .stoppingTunnel(session: session, onStopped: { + onStopped?() + completionHandler() + }) + case .stoppedTunnelTemporarily: + self.state = .stoppedTunnel + completionHandler() } self.networkMonitor?.cancel() self.networkMonitor = nil - - self.state = .stopped - - completionHandler(nil) } } + /// Get the current set of resources in the completionHandler. + /// If unchanged since referenceVersionString, call completionHandler(nil). public func getDisplayableResourcesIfVersionDifferentFrom( referenceVersionString: String, completionHandler: @escaping (DisplayableResources?) -> Void) { - workQueue.async { + workQueue.async { [weak self] in + guard let self = self else { return } + if referenceVersionString == self.displayableResources.versionString { completionHandler(nil) } else { @@ -133,209 +186,255 @@ public class Adapter { } } } +} - public func generateNetworkSettings( - addresses4: [String] = ["100.100.111.2"], addresses6: [String] = ["fd00:0222:2011:1111::2"], - ipv4Routes: [NEIPv4Route], ipv6Routes: [NEIPv6Route] - ) - -> NEPacketTunnelNetworkSettings - { - // The destination IP that connlib will assign our DNS proxy to. - let dnsSentinel = "1.1.1.1" +// MARK: Responding to path updates - // We can probably do better than this; see https://www.rfc-editor.org/info/rfc4821 - // But stick with something simple for now. 1280 is the minimum that will work for IPv6. - let mtu = 1280 - - // TODO: replace these with IPs returned from the connect call to portal - let subnetmask = "255.192.0.0" - let networkPrefixLength = NSNumber(value: 64) - - /* iOS requires a tunnel endpoint, whereas in WireGuard it's valid for - * a tunnel to have no endpoint, or for there to be many endpoints, in - * which case, displaying a single one in settings doesn't really - * make sense. So, we fill it in with this placeholder, which is not - * a valid IP address that will actually route over the Internet. - */ - let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") - let dnsSettings = NEDNSSettings(servers: [dnsSentinel]) - - // All DNS queries must first go through the tunnel's DNS - dnsSettings.matchDomains = [""] - networkSettings.dnsSettings = dnsSettings - networkSettings.mtu = NSNumber(value: mtu) - - let ipv4Settings = NEIPv4Settings( - addresses: addresses4, - subnetMasks: [subnetmask]) - ipv4Settings.includedRoutes = ipv4Routes - networkSettings.ipv4Settings = ipv4Settings - - let ipv6Settings = NEIPv6Settings( - addresses: addresses6, - networkPrefixLengths: [networkPrefixLength]) - ipv6Settings.includedRoutes = ipv6Routes - networkSettings.ipv6Settings = ipv6Settings - - return networkSettings - } - - public func setNetworkSettings(_ networkSettings: NEPacketTunnelNetworkSettings) throws { - var systemError: Error? - let condition = NSCondition() - - // Activate the condition - condition.lock() - defer { condition.unlock() } - - self.packetTunnelProvider?.setTunnelNetworkSettings(networkSettings) { error in - systemError = error - condition.signal() - } - - // Packet tunnel's `setTunnelNetworkSettings` times out in certain - // scenarios & never calls the given callback. - let setTunnelNetworkSettingsTimeout: TimeInterval = 5 // seconds - - if condition.wait(until: Date().addingTimeInterval(setTunnelNetworkSettingsTimeout)) { - if let systemError = systemError { - throw AdapterError.setNetworkSettings(systemError) - } - } - - // Save the latest applied network settings if there was no error. - if systemError != nil { - self.lastNetworkSettings = networkSettings - } - } - - /// Update runtime configuration. - /// - Parameters: - /// - ipv4Routes: IPv4 routes to send through the tunnel. - /// - ipv6Routes: IPv6 routes to send through the tunnel. - /// - completionHandler: completion handler. - public func update( - ipv4Routes: [NEIPv4Route], ipv6Routes: [NEIPv6Route], - completionHandler: @escaping (AdapterError?) -> Void - ) { - workQueue.async { - if case .stopped = self.state { - completionHandler(.invalidState) - return - } - - // Tell the system that the tunnel is going to reconnect using new WireGuard - // configuration. - // This will broadcast the `NEVPNStatusDidChange` notification to the GUI process. - self.packetTunnelProvider?.reasserting = true - defer { - self.packetTunnelProvider?.reasserting = false - } - - do { - try self.setNetworkSettings( - self.generateNetworkSettings(ipv4Routes: ipv4Routes, ipv6Routes: ipv6Routes)) - - switch self.state { - case .started(let wrappedSession): - self.state = .started(wrappedSession) - - case .temporaryShutdown: - self.state = .temporaryShutdown - - case .stopped: - fatalError() - } - - completionHandler(nil) - } catch let error as AdapterError { - completionHandler(error) - } catch { - fatalError() - } +extension Adapter { + private func beginPathMonitoring() { + self.logger.debug("Beginning path monitoring") + let networkMonitor = NWPathMonitor() + networkMonitor.pathUpdateHandler = { [weak self] path in + self?.didReceivePathUpdate(path: path) } + networkMonitor.start(queue: self.workQueue) } private func didReceivePathUpdate(path: Network.NWPath) { - #if os(macOS) - if case .started(let wrappedSession) = self.state { - self.logger.log(level: .debug, "Suppressing call to bumpSockets()") - // wrappedSession.bumpSockets() - } - #elseif os(iOS) - switch self.state { - case .started(let wrappedSession): + // Will be invoked in the workQueue by the path monitor + switch self.state { + + case .startingTunnel(let session, let onStarted): + if path.status != .satisfied { + self.logger.debug("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.") + onStarted?(nil) + self.packetTunnelProvider?.reasserting = true + self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil) + // FIXME: Is it ok to call disconnect() while we haven't got onTunnelReady? + _ = session.disconnect() + } + + case .tunnelReady(let session): if path.status == .satisfied { - self.logger.log(level: .debug, "Suppressing calls to disableSomeRoamingForBrokenMobileSemantics() and bumpSockets()") + self.logger.debug("Suppressing calls to disableSomeRoamingForBrokenMobileSemantics() and bumpSockets()") + // #if os(iOS) // wrappedSession.disableSomeRoamingForBrokenMobileSemantics() + // #endif // wrappedSession.bumpSockets() } else { - //self.logger.log(.debug, "Connectivity offline, pausing backend.") - self.state = .temporaryShutdown - wrappedSession.disconnect() + self.logger.debug("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.") + self.packetTunnelProvider?.reasserting = true + self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil) + _ = session.disconnect() } - case .temporaryShutdown: + case .stoppingTunnelTemporarily: + break + + case .stoppedTunnelTemporarily: guard path.status == .satisfied else { return } - self.logger.log(level: .debug, "Connectivity online, resuming backend.") + self.logger.debug("Adapter.didReceivePathUpdate: Back online. Starting connlib.") do { - try self.setNetworkSettings(self.lastNetworkSettings!) - - self.state = .started( - try WrappedSession.connect("http://localhost:4568", "test-token", self.callbackHandler) + self.state = .startingTunnel( + session: try WrappedSession.connect(portalURLString, token, self.callbackHandler), + onStarted: { error in + if let error = error { + self.logger.error("Adapter.didReceivePathUpdate: Error starting connlib: \(error, privacy: .public)") + self.packetTunnelProvider?.cancelTunnelWithError(error) + } else { + self.packetTunnelProvider?.reasserting = false + } + } ) + } catch let error as AdapterError { + self.logger.error("Adapter.didReceivePathUpdate: Error: \(error, privacy: .public)") } catch { - self.logger.log(level: .debug, "Failed to restart backend: \(error.localizedDescription)") + self.logger.error("Adapter.didReceivePathUpdate: Unknown error: \(error, privacy: .public) (fatal)") } - case .stopped: + case .stoppingTunnel, .stoppedTunnel: // no-op break - } - #else - #error("Unsupported") - #endif + } } } +// MARK: Implementing CallbackHandlerDelegate + extension Adapter: CallbackHandlerDelegate { - public func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String) { - // Unimplemented + public func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String) { + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onSetInterfaceConfig") + + switch self.state { + case .startingTunnel: + self.networkSettings = NetworkSettings( + tunnelAddressIPv4: tunnelAddressIPv4, tunnelAddressIPv6: tunnelAddressIPv6, + dnsAddress: dnsAddress, dnsFallbackStrategy: NetworkSettings.DNSFallbackStrategy(dnsFallbackStrategy)) + case .tunnelReady: + if let networkSettings = self.networkSettings { + networkSettings.setDNSFallbackStrategy(NetworkSettings.DNSFallbackStrategy(dnsFallbackStrategy)) + if let packetTunnelProvider = self.packetTunnelProvider { + networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) + } + } + + case .stoppingTunnel, .stoppedTunnel, .stoppingTunnelTemporarily, .stoppedTunnelTemporarily: + // This is not expected to happen + break + } + } } public func onTunnelReady() { - // Unimplemented + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onTunnelReady") + guard case .startingTunnel(let session, let onStarted) = self.state else { + self.logger.error("Adapter.onTunnelReady: Unexpected state: \(self.state, privacy: .public)") + return + } + guard let networkSettings = self.networkSettings else { + self.logger.error("Adapter.onTunnelReady: No network settings") + return + } + guard let packetTunnelProvider = self.packetTunnelProvider else { + self.logger.error("Adapter.onTunnelReady: No packet tunnel provider") + return + } + networkSettings.apply(on: packetTunnelProvider, logger: self.logger) { error in + if let error = error { + onStarted?(AdapterError.setNetworkSettings(error)) + self.state = .stoppedTunnel + } else { + onStarted?(nil) + self.state = .tunnelReady(session: session) + self.beginPathMonitoring() + } + } + } } - public func onAddRoute(_: String) { - // Unimplemented + public func onAddRoute(_ route: String) { + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onAddRoute(\(route, privacy: .public))") + guard let networkSettings = self.networkSettings else { + self.logger.error("Adapter.onAddRoute: No network settings") + return + } + guard let packetTunnelProvider = self.packetTunnelProvider else { + self.logger.error("Adapter.onAddRoute: No packet tunnel provider") + return + } + + networkSettings.addRoute(route) + if case .tunnelReady = self.state { + networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) + } + } } - public func onRemoveRoute(_: String) { - // Unimplemented + public func onRemoveRoute(_ route: String) { + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onRemoveRoute(\(route, privacy: .public))") + guard let networkSettings = self.networkSettings else { + self.logger.error("Adapter.onRemoveRoute: No network settings") + return + } + guard let packetTunnelProvider = self.packetTunnelProvider else { + self.logger.error("Adapter.onRemoveRoute: No packet tunnel provider") + return + } + networkSettings.removeRoute(route) + if case .tunnelReady = self.state { + networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) + } + } } public func onUpdateResources(resourceList: String) { - workQueue.async { - let jsonString = "[\(resourceList)]" + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onUpdateResources") + let jsonString = resourceList guard let jsonData = jsonString.data(using: .utf8) else { return } guard let networkResources = try? JSONDecoder().decode([NetworkResource].self, from: jsonData) else { return } + + // Note down the resources self.displayableResources.update(resources: networkResources.map { $0.displayableResource }) + + // Update DNS in case resource domains is changing + guard let networkSettings = self.networkSettings else { + self.logger.error("Adapter.onUpdateResources: No network settings") + return + } + guard let packetTunnelProvider = self.packetTunnelProvider else { + self.logger.error("Adapter.onUpdateResources: No packet tunnel provider") + return + } + let updatedResourceDomains = networkResources.compactMap { $0.resourceLocation.domain } + networkSettings.setResourceDomains(updatedResourceDomains) + if case .tunnelReady = self.state { + networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) + } } } public func onDisconnect(error: Optional) { - // Unimplemented + workQueue.async { [weak self] in + guard let self = self else { return } + + self.logger.debug("Adapter.onDisconnect") + if let errorMessage = error { + self.logger.error("Connlib disconnected with unrecoverable error: \(errorMessage, privacy: .public)") + switch self.state { + case .stoppingTunnel(session: _, let onStopped): + onStopped?() + self.state = .stoppedTunnel + case .stoppingTunnelTemporarily(session: _, let onStopped): + onStopped?() + self.state = .stoppedTunnel + case .stoppedTunnel: + // This should not happen + break + case .stoppedTunnelTemporarily: + self.state = .stoppedTunnel + default: + self.packetTunnelProvider?.cancelTunnelWithError(AdapterError.connlibFatalError(errorMessage)) + self.state = .stoppedTunnel + } + } else { + self.logger.debug("Connlib disconnected") + switch self.state { + case .stoppingTunnel(session: _, let onStopped): + onStopped?() + self.state = .stoppedTunnel + case .stoppingTunnelTemporarily(session: _, let onStopped): + onStopped?() + self.state = .stoppedTunnelTemporarily + default: + // This should not happen + self.state = .stoppedTunnel + } + } + } } public func onError(error: String) { - let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel") - logger.log(level: .error, "Internal connlib error: \(error, privacy: .public)") + self.logger.error("Internal connlib error: \(error, privacy: .public)") } } diff --git a/swift/apple/FirezoneNetworkExtension/NetworkResource.swift b/swift/apple/FirezoneNetworkExtension/NetworkResource.swift index eafe5028e..e250700be 100644 --- a/swift/apple/FirezoneNetworkExtension/NetworkResource.swift +++ b/swift/apple/FirezoneNetworkExtension/NetworkResource.swift @@ -17,6 +17,13 @@ public struct NetworkResource: Decodable { case .cidr(let cidrAddress): return cidrAddress } } + + var domain: String? { + switch self { + case .dns(let domain, ipv4: _, ipv6: _): return domain + case .cidr(cidrAddress: _): return nil + } + } } let name: String diff --git a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift new file mode 100644 index 000000000..f27797efd --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift @@ -0,0 +1,194 @@ +// +// NetworkSettings.swift +// (c) 2023 Firezone, Inc. +// LICENSE: Apache-2.0 + +import Foundation +import NetworkExtension +import os.log + +class NetworkSettings { + + enum DNSFallbackStrategy: String { + // How to handle DNS requests for domains not handled by Firezone + case systemResolver = "system_resolver" // Have the OS handle it using split-DNS + case upstreamResolver = "upstream_resolver" // Have connlib pass it on to a user-specified DNS server + + init(_ string: String) { + if string == "upstream_resolver" { + self = .upstreamResolver + } else if string == "system_resolver" { + self = .systemResolver + } else { + // silent default + self = .systemResolver + } + } + } + + // Unchanging values + let tunnelAddressIPv4: String + let tunnelAddressIPv6: String + let dnsAddress: String + + // Modifiable values + private(set) var dnsFallbackStrategy: DNSFallbackStrategy + private(set) var routes: [String] = [] + private(set) var resourceDomains: [String] = [] + + // To keep track of modifications + private(set) var hasUnappliedChanges: Bool + + init(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: DNSFallbackStrategy) { + self.tunnelAddressIPv4 = tunnelAddressIPv4 + self.tunnelAddressIPv6 = tunnelAddressIPv6 + self.dnsAddress = dnsAddress + self.dnsFallbackStrategy = dnsFallbackStrategy + self.hasUnappliedChanges = true + } + + func setDNSFallbackStrategy(_ dnsFallbackStrategy: DNSFallbackStrategy) { + if self.dnsFallbackStrategy != dnsFallbackStrategy { + self.dnsFallbackStrategy = dnsFallbackStrategy + self.hasUnappliedChanges = true + } + } + + func addRoute(_ route: String) { + if !self.routes.contains(route) { + self.routes.append(route) + self.hasUnappliedChanges = true + } + } + + func removeRoute(_ route: String) { + if self.routes.contains(route) { + self.routes.removeAll(where: { $0 == route }) + self.hasUnappliedChanges = true + } + } + + func setResourceDomains(_ resourceDomains: [String]) { + let sortedResourceDomains = resourceDomains.sorted() + if self.resourceDomains != sortedResourceDomains { + self.resourceDomains = sortedResourceDomains + if dnsFallbackStrategy == .systemResolver { + self.hasUnappliedChanges = true + } + } + } + + func apply(on packetTunnelProvider: NEPacketTunnelProvider, logger: Logger, completionHandler: ((Error?) -> Void)?) { + + guard self.hasUnappliedChanges else { + logger.error("NetworkSettings.apply: No changes to apply") + completionHandler?(nil) + return + } + + logger.debug("NetworkSettings.apply: Applying network settings") + + var tunnelIPv4Routes: [NEIPv4Route] = [] + var tunnelIPv6Routes: [NEIPv6Route] = [] + + for route in routes { + let components = route.split(separator: "/") + guard components.count == 2 else { + logger.error("NetworkSettings.apply: Ignoring invalid route '\(route, privacy: .public)'") + continue + } + let address = String(components[0]) + let networkPrefixLengthString = String(components[1]) + if let groupSeparator = address.first(where: { $0 == "." || $0 == ":"}) { + if groupSeparator == "." { // IPv4 address + if IPv4Address(address) == nil { + logger.error("NetworkSettings.apply: Ignoring invalid IPv4 address '\(address, privacy: .public)'") + continue + } + let validNetworkPrefixLength = Self.validNetworkPrefixLength(fromString: networkPrefixLengthString, maximumValue: 32) + let ipv4SubnetMask = Self.ipv4SubnetMask(networkPrefixLength: validNetworkPrefixLength) + logger.debug("NetworkSettings.apply: Adding IPv4 route: \(address) (subnet mask: \(ipv4SubnetMask))") + tunnelIPv4Routes.append(NEIPv4Route(destinationAddress: address, subnetMask: ipv4SubnetMask)) + } + if groupSeparator == ":" { // IPv6 address + if IPv6Address(address) == nil { + logger.error("NetworkSettings.apply: Ignoring invalid IPv6 address '\(address, privacy: .public)'") + continue + } + let validNetworkPrefixLength = Self.validNetworkPrefixLength(fromString: networkPrefixLengthString, maximumValue: 128) + logger.debug("NetworkSettings.apply: Adding IPv6 route: \(address) (prefix length: \(validNetworkPrefixLength))") + tunnelIPv6Routes.append(NEIPv6Route(destinationAddress: address, networkPrefixLength: NSNumber(integerLiteral: validNetworkPrefixLength))) + } + } else { + logger.error("NetworkSettings.apply: Ignoring invalid route '\(route, privacy: .public)'") + } + } + + // We don't really know the connlib gateway IP address at this point, but just using 127.0.0.1 is okay + // because the OS doesn't really need this IP address. + // NEPacketTunnelNetworkSettings taking in tunnelRemoteAddress is probably a bad abstraction caused by + // NEPacketTunnelNetworkSettings inheriting from NETunnelNetworkSettings. + + let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + + let ipv4Settings = NEIPv4Settings(addresses: [tunnelAddressIPv4], subnetMasks: ["255.255.255.255"]) + ipv4Settings.includedRoutes = tunnelIPv4Routes + tunnelNetworkSettings.ipv4Settings = ipv4Settings + + let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6], networkPrefixLengths: [128]) + ipv6Settings.includedRoutes = tunnelIPv6Routes + tunnelNetworkSettings.ipv6Settings = ipv6Settings + + let dnsSettings = NEDNSSettings(servers: [dnsAddress]) + switch dnsFallbackStrategy { + case .systemResolver: + // Enable split-DNS. Only those domains matching the resources will be sent to the tunnel's DNS. + dnsSettings.matchDomains = resourceDomains + case .upstreamResolver: + // All DNS queries go to the tunnel's DNS. + dnsSettings.matchDomains = [""] + } + tunnelNetworkSettings.dnsSettings = dnsSettings + + self.hasUnappliedChanges = false + packetTunnelProvider.setTunnelNetworkSettings(tunnelNetworkSettings) { error in + if let error = error { + logger.error("NetworkSettings.apply: Error: \(error, privacy: .public)") + } else { + guard !self.hasUnappliedChanges else { + // Changes were made while the packetTunnelProvider was setting the network settings + logger.debug("NetworkSettings.apply: Applying changes made to network settings while we were applying the network settings") + self.apply(on: packetTunnelProvider, logger: logger, completionHandler: completionHandler) + return + } + logger.debug("NetworkSettings.apply: Applied successfully") + } + completionHandler?(error) + } + } +} + +private extension NetworkSettings { + private static func validNetworkPrefixLength(fromString string: String, maximumValue: Int) -> Int { + guard let networkPrefixLength = Int(string) else { return 0 } + if networkPrefixLength < 0 { return 0 } + if networkPrefixLength > maximumValue { return maximumValue } + return networkPrefixLength + } + + private static func ipv4SubnetMask(networkPrefixLength: Int) -> String { + precondition(networkPrefixLength >= 0 && networkPrefixLength <= 32) + let mask: UInt32 = 0xFFFFFFFF + let maxPrefixLength = 32 + let octets = 4 + + let subnetMask = mask & (mask << (maxPrefixLength - networkPrefixLength)) + var parts: [String] = [] + for idx in 0...(octets - 1) { + let part = String(UInt32(0x000000FF) & (subnetMask >> ((octets - 1 - idx) * 8)), radix: 10) + parts.append(part) + } + + return parts.joined(separator: ".") + } +} diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 2cef17484..e615258f2 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -16,7 +16,7 @@ enum PacketTunnelProviderError: String, Error { class PacketTunnelProvider: NEPacketTunnelProvider { static let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel") - private lazy var adapter = Adapter(with: self) + private var adapter: Adapter? override func startTunnel( options _: [String: NSObject]? = nil, @@ -28,7 +28,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } let providerConfiguration = tunnelProviderProtocol.providerConfiguration - guard let portalURL = providerConfiguration?["portalURL"] as? String else { + guard let portalURLString = providerConfiguration?["portalURL"] as? String else { completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid) return } @@ -36,14 +36,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid) return } - - Self.logger.log("portalURL = \(portalURL, privacy: .public)") - Self.logger.log("token = \(token, privacy: .public)") - + let adapter = Adapter(portalURLString: portalURLString, token: token, packetTunnelProvider: self) + self.adapter = adapter do { - // Once connlib is updated to take in portalURL and token, this call - // should become adapter.start(portalURL: portalURL, token: token) - try adapter.start { error in + try adapter.start() { error in if let error { Self.logger.error("Error in adapter.start: \(error)") } @@ -55,24 +51,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) { - adapter.stop { error in - if let error { - Self.logger.error("Error in adapter.stop: \(error)") - } + adapter?.stop { completionHandler() + #if os(macOS) + // HACK: This is a filthy hack to work around Apple bug 32073323 + exit(0) + #endif } - - #if os(macOS) - // HACK: This is a filthy hack to work around Apple bug 32073323 (dup'd by us as 47526107). - // Remove it when they finally fix this upstream and the fix has been rolled out to - // sufficient quantities of users. - exit(0) - #endif } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) { let query = String(data: messageData, encoding: .utf8) ?? "" - adapter.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) { displayableResources in + adapter?.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) { displayableResources in completionHandler?(displayableResources?.toData()) } }