mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
This PR eliminates JSON-based communication across the FFI boundary, replacing it with proper uniffi-generated types for improved type safety, performance, and reliability. We replace JSON string parameters with native uniffi types for: - Resources (DNS, CIDR, Internet) - Device information - DNS server lists - Network routes (CIDR representation) Also, get rid of JSON serialisation in Swift client IPC in favour of PropertyList based serialisation. Fixes: https://github.com/firezone/firezone/issues/9548 --------- Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
610 lines
20 KiB
Swift
610 lines
20 KiB
Swift
//
|
|
// Adapter.swift
|
|
// (c) 2024-2025 Firezone, Inc.
|
|
// LICENSE: Apache-2.0
|
|
//
|
|
|
|
// TODO: Refactor to fix file length
|
|
|
|
import CryptoKit
|
|
import FirezoneKit
|
|
import Foundation
|
|
import NetworkExtension
|
|
import OSLog
|
|
|
|
enum AdapterError: Error {
|
|
/// Failure to perform an operation in such state.
|
|
case invalidSession(Session?)
|
|
|
|
/// connlib failed to start
|
|
case connlibConnectError(String)
|
|
|
|
case setDnsError(String)
|
|
|
|
var localizedDescription: String {
|
|
switch self {
|
|
case .invalidSession(let session):
|
|
let message = session == nil ? "Session is disconnected" : "Session is still connected"
|
|
return message
|
|
case .connlibConnectError(let error):
|
|
return "connlib failed to start: \(error)"
|
|
case .setDnsError(let error):
|
|
return "failed to set new DNS serversn: \(error)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Loosely inspired from WireGuardAdapter from WireGuardKit
|
|
class Adapter: @unchecked Sendable {
|
|
|
|
/// Command sender for sending commands to the session
|
|
private var commandSender: Sender<SessionCommand>?
|
|
|
|
/// Task handles for explicit cancellation during cleanup
|
|
private var eventLoopTask: Task<Void, Never>?
|
|
private var eventConsumerTask: Task<Void, Never>?
|
|
|
|
// Our local copy of the accountSlug
|
|
private let accountSlug: String
|
|
|
|
/// Network settings for tunnel configuration.
|
|
private var networkSettings: NetworkSettings?
|
|
|
|
/// Packet tunnel provider.
|
|
private weak var packetTunnelProvider: PacketTunnelProvider?
|
|
|
|
/// Network routes monitor.
|
|
private var networkMonitor: NWPathMonitor?
|
|
|
|
#if os(macOS)
|
|
/// Used for finding system DNS resolvers on macOS when network conditions have changed.
|
|
private let systemConfigurationResolvers = SystemConfigurationResolvers()
|
|
#endif
|
|
|
|
/// Remembers the last _relevant_ path update.
|
|
/// A path update is considered relevant if certain properties change that require us to reset connlib's
|
|
/// network state.
|
|
private var lastPath: Network.NWPath?
|
|
|
|
/// Private queue used to ensure consistent ordering among path update and connlib callbacks
|
|
/// This is the primary async primitive used in this class.
|
|
private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue")
|
|
|
|
/// Primary callback we receive whenever:
|
|
/// - Network connectivity changes
|
|
/// - System DNS servers change, including when we set them
|
|
/// - Routes change, including when we set them
|
|
///
|
|
/// Apple doesn't give us very much info in this callback, so we don't know which of the
|
|
/// events above triggered the callback.
|
|
///
|
|
/// On iOS this creates a problem:
|
|
///
|
|
/// We have no good way to get the System's default resolvers. We use a workaround which
|
|
/// involves reading the resolvers from Bind (i.e. /etc/resolv.conf) but this will be set to connlib's
|
|
/// DNS sentinel while the tunnel is active, which isn't helpful to us. To get around this, we can
|
|
/// very briefly update the Tunnel's matchDomains config to *not* be the catch-all [""], which
|
|
/// causes iOS to write the actual system resolvers into /etc/resolv.conf, which we can then read.
|
|
/// The issue is that this in itself causes a path update callback, which makes it hard to
|
|
/// differentiate between us changing the DNS configuration and the system actually receiving new
|
|
/// default resolvers.
|
|
///
|
|
/// So we solve this problem by only doing this DNS dance if the gateways available to the path have
|
|
/// changed. This means we only call setDns when the physical network has changed, and therefore
|
|
/// we're blind to path updates where only the DNS resolvers have changed. That will happen in two
|
|
/// cases most commonly:
|
|
/// - New DNS servers were set by DHCP
|
|
/// - The user manually changed the DNS servers in the system settings
|
|
///
|
|
/// For now, this will break DNS if the old servers connlib is using are no longer valid, and
|
|
/// can only be fixed with a sign out and sign back in which restarts the NetworkExtension.
|
|
///
|
|
/// On macOS, Apple has exposed the SystemConfiguration framework which makes this easy and
|
|
/// doesn't suffer from this issue.
|
|
///
|
|
/// See the following issues for discussion around the above issue:
|
|
/// - https://github.com/firezone/firezone/issues/3302
|
|
/// - https://github.com/firezone/firezone/issues/3343
|
|
/// - https://github.com/firezone/firezone/issues/3235
|
|
/// - https://github.com/firezone/firezone/issues/3175
|
|
private lazy var pathUpdateHandler: (Network.NWPath) -> Void = { [weak self] path in
|
|
guard let self else { return }
|
|
|
|
if path.status == .unsatisfied {
|
|
// Check if we need to set reasserting, avoids OS log spam and potentially other side effects
|
|
if self.packetTunnelProvider?.reasserting == false {
|
|
// Tell the UI we're not connected
|
|
self.packetTunnelProvider?.reasserting = true
|
|
}
|
|
} else {
|
|
if self.packetTunnelProvider?.reasserting == true {
|
|
self.packetTunnelProvider?.reasserting = false
|
|
}
|
|
|
|
if path.connectivityDifferentFrom(path: lastPath) {
|
|
// Tell connlib to reset network state and DNS resolvers, but only do so if our connectivity has
|
|
// meaningfully changed. On darwin, this is needed to send packets
|
|
// out of a different interface even when 0.0.0.0 is used as the source.
|
|
// If our primary interface changes, we can be certain the old socket shouldn't be
|
|
// used anymore.
|
|
self.sendCommand(.reset("primary network path changed"))
|
|
}
|
|
|
|
setSystemDefaultResolvers(path)
|
|
|
|
lastPath = path
|
|
}
|
|
}
|
|
|
|
/// Internet resource enabled state
|
|
private var internetResourceEnabled: Bool
|
|
|
|
/// Keep track of resources for UI
|
|
private var resources: [Resource]?
|
|
|
|
/// Starting parameters
|
|
private let apiURL: String
|
|
private let token: Token
|
|
private let deviceId: String
|
|
private let logFilter: String
|
|
|
|
init(
|
|
apiURL: String,
|
|
token: Token,
|
|
deviceId: String,
|
|
logFilter: String,
|
|
accountSlug: String,
|
|
internetResourceEnabled: Bool,
|
|
packetTunnelProvider: PacketTunnelProvider
|
|
) {
|
|
self.apiURL = apiURL
|
|
self.token = token
|
|
self.deviceId = deviceId
|
|
self.logFilter = logFilter
|
|
self.accountSlug = accountSlug
|
|
self.internetResourceEnabled = internetResourceEnabled
|
|
self.packetTunnelProvider = packetTunnelProvider
|
|
}
|
|
|
|
// Could happen abruptly if the process is killed.
|
|
deinit {
|
|
Log.log("Adapter.deinit")
|
|
|
|
// Cancel all Tasks - this triggers cooperative cancellation
|
|
// Event loop checks Task.isCancelled in its polling loop
|
|
// Event consumer will exit when eventSender.deinit closes the stream
|
|
eventLoopTask?.cancel()
|
|
eventConsumerTask?.cancel()
|
|
|
|
// Cancel network monitor
|
|
networkMonitor?.cancel()
|
|
}
|
|
|
|
func start() throws {
|
|
Log.log("Adapter.start: Starting session for account: \(accountSlug)")
|
|
|
|
// Get device metadata
|
|
let deviceName = DeviceMetadata.getDeviceName()
|
|
let osVersion = DeviceMetadata.getOSVersion()
|
|
let logDir = SharedAccess.connlibLogFolderURL?.path ?? "/tmp/firezone"
|
|
|
|
#if os(iOS)
|
|
let deviceInfo = DeviceInfo(
|
|
firebaseInstallationId: nil,
|
|
deviceUuid: nil,
|
|
deviceSerial: nil,
|
|
identifierForVendor: DeviceMetadata.getIdentifierForVendor()
|
|
)
|
|
#else
|
|
let deviceInfo = DeviceInfo(
|
|
firebaseInstallationId: nil,
|
|
deviceUuid: getDeviceUuid(),
|
|
deviceSerial: getDeviceSerial(),
|
|
identifierForVendor: nil
|
|
)
|
|
#endif
|
|
|
|
// Create the session
|
|
let session: Session
|
|
do {
|
|
session = try Session.newApple(
|
|
apiUrl: apiURL,
|
|
token: token.description,
|
|
deviceId: deviceId,
|
|
accountSlug: accountSlug,
|
|
deviceName: deviceName,
|
|
osVersion: osVersion,
|
|
logDir: logDir,
|
|
logFilter: logFilter,
|
|
deviceInfo: deviceInfo,
|
|
isInternetResourceActive: internetResourceEnabled
|
|
)
|
|
} catch {
|
|
throw AdapterError.connlibConnectError(String(describing: error))
|
|
}
|
|
|
|
// Create channels - following Rust pattern with separate sender/receiver
|
|
let (commandSender, commandReceiver): (Sender<SessionCommand>, Receiver<SessionCommand>) =
|
|
Channel.create()
|
|
self.commandSender = commandSender
|
|
|
|
let (eventSender, eventReceiver): (Sender<Event>, Receiver<Event>) = Channel.create()
|
|
|
|
// Start event loop - owns session, receives commands, sends events
|
|
eventLoopTask = Task { [weak self] in
|
|
defer {
|
|
// ALWAYS cleanup, even if event loop crashes
|
|
self?.commandSender = nil
|
|
Log.log("Adapter: Event loop finished, session dropped")
|
|
}
|
|
|
|
await runSessionEventLoop(
|
|
session: session,
|
|
commandReceiver: commandReceiver,
|
|
eventSender: eventSender
|
|
)
|
|
}
|
|
|
|
// Start event consumer - consumes events from receiver (Rust pattern: receiver outside)
|
|
eventConsumerTask = Task { [weak self] in
|
|
for await event in eventReceiver.stream {
|
|
// Check self on each iteration - if Adapter is deallocated, stop processing events
|
|
guard let self = self else {
|
|
Log.log("Adapter: Event consumer stopping - Adapter deallocated")
|
|
break
|
|
}
|
|
|
|
await self.handleEvent(event)
|
|
}
|
|
|
|
Log.log("Adapter: Event consumer finished")
|
|
}
|
|
|
|
// Configure DNS and path monitoring
|
|
startNetworkPathMonitoring()
|
|
|
|
Log.log("Adapter.start: Session started successfully")
|
|
}
|
|
|
|
/// Final callback called by packetTunnelProvider when tunnel is to be stopped.
|
|
/// Can happen due to:
|
|
/// - User toggles VPN off in Settings.app
|
|
/// - User signs out
|
|
/// - User clicks "Disconnect and Quit" (macOS)
|
|
/// - connlib sends onDisconnect
|
|
///
|
|
/// This can happen before the tunnel is in the tunnelReady state, such as if the portal
|
|
/// is slow to send the init.
|
|
func stop() {
|
|
Log.log("Adapter.stop")
|
|
|
|
sendCommand(.disconnect)
|
|
|
|
networkMonitor?.cancel()
|
|
networkMonitor = nil
|
|
|
|
// Tasks will finish naturally after disconnect command is processed
|
|
// No need to cancel them here - they'll clean up via their defer blocks
|
|
}
|
|
|
|
/// Get the current set of resources in the completionHandler, only returning
|
|
/// them if the resource list has changed.
|
|
func getResourcesIfVersionDifferentFrom(
|
|
hash: Data, completionHandler: @escaping (Data?) -> Void
|
|
) {
|
|
// This is async to avoid blocking the main UI thread
|
|
workQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Convert uniffi resources to FirezoneKit resources and encode with PropertyList
|
|
let propertyListData: Data
|
|
if let uniffiResources = self.resources {
|
|
let firezoneResources = uniffiResources.map { self.convertResource($0) }
|
|
guard let encoded = try? PropertyListEncoder().encode(firezoneResources) else {
|
|
Log.log("Failed to encode resources as PropertyList")
|
|
completionHandler(nil)
|
|
return
|
|
}
|
|
propertyListData = encoded
|
|
} else {
|
|
propertyListData = Data()
|
|
}
|
|
|
|
if hash == Data(SHA256.hash(data: propertyListData)) {
|
|
// nothing changed
|
|
completionHandler(nil)
|
|
} else {
|
|
completionHandler(propertyListData)
|
|
}
|
|
}
|
|
}
|
|
|
|
func reset(reason: String, path: Network.NWPath? = nil) {
|
|
sendCommand(.reset(reason))
|
|
|
|
if let path = (path ?? lastPath) {
|
|
setSystemDefaultResolvers(path)
|
|
}
|
|
}
|
|
|
|
func setInternetResourceEnabled(_ enabled: Bool) {
|
|
workQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
self.internetResourceEnabled = enabled
|
|
self.sendCommand(.setInternetResourceState(enabled))
|
|
}
|
|
}
|
|
|
|
// MARK: - Event handling
|
|
|
|
private func handleEvent(_ event: Event) async {
|
|
switch event {
|
|
case .tunInterfaceUpdated(
|
|
let ipv4, let ipv6, let dns, let searchDomain, let ipv4Routes, let ipv6Routes):
|
|
Log.log("Received TunInterfaceUpdated event")
|
|
|
|
// Convert UniFFI types to NetworkExtension types
|
|
let routes4 = ipv4Routes.compactMap { cidr in
|
|
NetworkSettings.Cidr(address: cidr.address, prefix: Int(cidr.prefix)).asNEIPv4Route
|
|
}
|
|
let routes6 = ipv6Routes.compactMap { cidr in
|
|
NetworkSettings.Cidr(address: cidr.address, prefix: Int(cidr.prefix)).asNEIPv6Route
|
|
}
|
|
|
|
// All decoding succeeded - now apply settings atomically
|
|
guard let provider = packetTunnelProvider else {
|
|
Log.error(AdapterError.invalidSession(nil))
|
|
return
|
|
}
|
|
|
|
Log.log("Setting interface config")
|
|
|
|
let networkSettings = NetworkSettings(packetTunnelProvider: provider)
|
|
networkSettings.tunnelAddressIPv4 = ipv4
|
|
networkSettings.tunnelAddressIPv6 = ipv6
|
|
networkSettings.dnsAddresses = dns
|
|
networkSettings.routes4 = routes4
|
|
networkSettings.routes6 = routes6
|
|
networkSettings.setSearchDomain(domain: searchDomain)
|
|
self.networkSettings = networkSettings
|
|
|
|
networkSettings.apply()
|
|
|
|
case .resourcesUpdated(let resourceList):
|
|
Log.log("Received ResourcesUpdated event with \(resourceList.count) resources")
|
|
|
|
// Store resource list
|
|
workQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.resources = resourceList
|
|
}
|
|
|
|
// Apply network settings to flush DNS cache when resources change
|
|
// This ensures new DNS resources are immediately resolvable
|
|
if let networkSettings = networkSettings {
|
|
Log.log("Reapplying network settings to flush DNS cache after resource update")
|
|
networkSettings.apply()
|
|
}
|
|
|
|
case .disconnected(let error):
|
|
let errorMessage = error.message()
|
|
Log.info("Received Disconnected event: \(errorMessage)")
|
|
|
|
guard let provider = packetTunnelProvider else {
|
|
Log.error(AdapterError.invalidSession(nil))
|
|
return
|
|
}
|
|
|
|
// If auth expired/is invalid, delete stored token and save the reason why so the GUI can act upon it.
|
|
if error.isAuthenticationError() {
|
|
// Delete stored token and save the reason for the GUI
|
|
do {
|
|
try Token.delete()
|
|
let reason: NEProviderStopReason = .authenticationCanceled
|
|
try String(reason.rawValue).write(
|
|
to: SharedAccess.providerStopReasonURL, atomically: true, encoding: .utf8)
|
|
} catch {
|
|
Log.error(error)
|
|
}
|
|
|
|
#if os(iOS)
|
|
// iOS notifications should be shown from the tunnel process
|
|
SessionNotification.showSignedOutNotificationiOS()
|
|
#endif
|
|
} else {
|
|
Log.warning("Disconnected with error: \(errorMessage)")
|
|
}
|
|
|
|
// Handle disconnection
|
|
provider.cancelTunnelWithError(nil)
|
|
}
|
|
}
|
|
|
|
private func startNetworkPathMonitoring() {
|
|
networkMonitor = NWPathMonitor()
|
|
networkMonitor?.pathUpdateHandler = self.pathUpdateHandler
|
|
networkMonitor?.start(queue: workQueue)
|
|
}
|
|
|
|
private func setSystemDefaultResolvers(_ path: Network.NWPath) {
|
|
// Step 1: Get system default resolvers
|
|
#if os(macOS)
|
|
let resolvers = self.systemConfigurationResolvers.getDefaultDNSServers(
|
|
interfaceName: path.availableInterfaces.first?.name)
|
|
#elseif os(iOS)
|
|
|
|
// DNS server updates don't necessarily trigger a connectivity change, but we'll get a path update callback
|
|
// nevertheless. Unfortunately there's no visible difference in instance properties between the two path
|
|
// objects. On macOS this isn't an issue because setting new resolvers here doesn't trigger a change.
|
|
// On iOS, however, we need to prevent path update loops by not reacting to path updates that we ourselves
|
|
// triggered by the network settings apply.
|
|
|
|
// TODO: Find a hackier hack to avoid this on iOS
|
|
if !path.connectivityDifferentFrom(path: lastPath) {
|
|
return
|
|
}
|
|
let resolvers = resetToSystemDNSGettingBindResolvers()
|
|
#endif
|
|
|
|
// Step 2: Validate and strip scope suffixes
|
|
var parsedResolvers: [String] = []
|
|
|
|
for stringAddress in resolvers {
|
|
if let ipv4Address = IPv4Address(stringAddress) {
|
|
if ipv4Address.isWithinSentinelRange() {
|
|
Log.warning(
|
|
"Not adding fetched system resolver because it's within sentinel range: \(ipv4Address)")
|
|
} else {
|
|
parsedResolvers.append("\(ipv4Address)")
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if let ipv6Address = IPv6Address(stringAddress) {
|
|
if ipv6Address.isWithinSentinelRange() {
|
|
Log.warning(
|
|
"Not adding fetched system resolver because it's within sentinel range: \(ipv6Address)")
|
|
} else {
|
|
parsedResolvers.append("\(ipv6Address)")
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
Log.warning("IP address \(stringAddress) did not parse as either IPv4 or IPv6")
|
|
}
|
|
|
|
// Step 3: Send to connlib
|
|
Log.log("Sending resolvers to connlib: \(parsedResolvers)")
|
|
sendCommand(.setDns(parsedResolvers))
|
|
}
|
|
|
|
private func sendCommand(_ command: SessionCommand) {
|
|
commandSender?.send(command)
|
|
}
|
|
|
|
// MARK: - Resource conversion (uniffi → FirezoneKit)
|
|
|
|
private func convertResource(_ resource: Resource) -> FirezoneKit.Resource {
|
|
switch resource {
|
|
case .dns(let dnsResource):
|
|
return FirezoneKit.Resource(
|
|
id: dnsResource.id,
|
|
name: dnsResource.name,
|
|
address: dnsResource.address,
|
|
addressDescription: dnsResource.addressDescription,
|
|
status: convertResourceStatus(dnsResource.status),
|
|
sites: dnsResource.sites.map { convertSite($0) },
|
|
type: .dns
|
|
)
|
|
case .cidr(let cidrResource):
|
|
return FirezoneKit.Resource(
|
|
id: cidrResource.id,
|
|
name: cidrResource.name,
|
|
address: cidrResource.address,
|
|
addressDescription: cidrResource.addressDescription,
|
|
status: convertResourceStatus(cidrResource.status),
|
|
sites: cidrResource.sites.map { convertSite($0) },
|
|
type: .cidr
|
|
)
|
|
case .internet(let internetResource):
|
|
return FirezoneKit.Resource(
|
|
id: internetResource.id,
|
|
name: internetResource.name,
|
|
address: nil,
|
|
addressDescription: nil,
|
|
status: convertResourceStatus(internetResource.status),
|
|
sites: internetResource.sites.map { convertSite($0) },
|
|
type: .internet
|
|
)
|
|
}
|
|
}
|
|
|
|
private func convertSite(_ site: Site) -> FirezoneKit.Site {
|
|
return FirezoneKit.Site(
|
|
id: site.id,
|
|
name: site.name
|
|
)
|
|
}
|
|
|
|
private func convertResourceStatus(_ status: ResourceStatus) -> FirezoneKit.ResourceStatus {
|
|
switch status {
|
|
case .unknown:
|
|
return .unknown
|
|
case .online:
|
|
return .online
|
|
case .offline:
|
|
return .offline
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: Getting System Resolvers on iOS
|
|
#if os(iOS)
|
|
extension Adapter {
|
|
// When the tunnel is up, we can only get the system's default resolvers
|
|
// by reading /etc/resolv.conf when matchDomains is set to a non-empty string.
|
|
// If matchDomains is an empty string, /etc/resolv.conf will contain connlib's
|
|
// sentinel, which isn't helpful to us.
|
|
private func resetToSystemDNSGettingBindResolvers() -> [String] {
|
|
guard let networkSettings = networkSettings
|
|
else {
|
|
// Network Settings hasn't been applied yet, so our sentinel isn't
|
|
// the system's resolver and we can grab the system resolvers directly.
|
|
// If we try to continue below without valid tunnel addresses assigned
|
|
// to the interface, we'll crash.
|
|
return BindResolvers.getServers()
|
|
}
|
|
|
|
var resolvers: [String] = []
|
|
|
|
// The caller is in an async context, so it's ok to block this thread here.
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
// Set tunnel's matchDomains to a dummy string that will never match any name
|
|
networkSettings.setDummyMatchDomain()
|
|
|
|
// Call apply to populate /etc/resolv.conf with the system's default resolvers
|
|
networkSettings.apply {
|
|
guard let networkSettings = self.networkSettings else { return }
|
|
|
|
// Only now can we get the system resolvers
|
|
resolvers = BindResolvers.getServers()
|
|
|
|
// Restore connlib's DNS resolvers
|
|
networkSettings.clearDummyMatchDomain()
|
|
networkSettings.apply { semaphore.signal() }
|
|
}
|
|
|
|
semaphore.wait()
|
|
return resolvers
|
|
}
|
|
}
|
|
#endif
|
|
|
|
extension Network.NWPath {
|
|
func connectivityDifferentFrom(path: Network.NWPath? = nil) -> Bool {
|
|
// We define a path as different from another if the following properties change
|
|
return path?.supportsIPv4 != self.supportsIPv4 || path?.supportsIPv6 != self.supportsIPv6
|
|
|| path?.supportsDNS != self.supportsDNS
|
|
|| path?.status != self.status
|
|
|| path?.availableInterfaces.first != self.availableInterfaces.first
|
|
|| path?.gateways != self.gateways
|
|
}
|
|
}
|
|
|
|
extension IPv4Address {
|
|
func isWithinSentinelRange() -> Bool {
|
|
return "\(self)".hasPrefix("100.100.111.")
|
|
}
|
|
}
|
|
|
|
extension IPv6Address {
|
|
func isWithinSentinelRange() -> Bool {
|
|
return "\(self)".hasPrefix("fd00:2021:1111:8000:100:100:111:")
|
|
}
|
|
}
|