From e76daaaab3dc0a10c5fdbbface9e8a77aaae369b Mon Sep 17 00:00:00 2001 From: Mariusz Klochowicz Date: Thu, 16 Oct 2025 15:45:31 +1030 Subject: [PATCH] refactor: remove JSON serialization from FFI boundary (#10575) 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 --- kotlin/android/app/build.gradle.kts | 2 +- .../firezone/android/tunnel/TunnelService.kt | 107 +++++--- rust/client-ffi/src/lib.rs | 229 +++++++++++++++--- rust/client-shared/src/lib.rs | 2 - rust/client-shared/src/serde_routelist.rs | 46 ---- .../FirezoneKit/Helpers/DeviceMetadata.swift | 25 +- .../FirezoneKit/Helpers/IPCClient.swift | 5 +- .../Sources/FirezoneKit/Models/Resource.swift | 6 +- .../Sources/FirezoneKit/Models/Site.swift | 2 +- .../FirezoneNetworkExtension/Adapter.swift | 138 ++++++++--- .../NetworkSettings.swift | 20 +- .../PacketTunnelProvider.swift | 4 +- .../SessionEventLoop.swift | 22 +- 13 files changed, 409 insertions(+), 199 deletions(-) delete mode 100644 rust/client-shared/src/serde_routelist.rs diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index bf6d07330..5649faf51 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -19,7 +19,7 @@ spotless { format("misc") { target("*.gradle", "*.md", ".gitignore") trimTrailingWhitespace() - indentWithSpaces() + leadingTabsToSpaces() endWithNewline() } kotlin { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index 792cb2ded..ab30e885f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -20,9 +20,6 @@ import android.util.Log import androidx.lifecycle.MutableLiveData import com.google.android.gms.tasks.Tasks import com.google.firebase.installations.FirebaseInstallations -import com.google.gson.FieldNamingPolicy -import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import dagger.hilt.android.AndroidEntryPoint @@ -31,6 +28,7 @@ import dev.firezone.android.core.data.ResourceState import dev.firezone.android.core.data.isEnabled import dev.firezone.android.tunnel.model.Cidr import dev.firezone.android.tunnel.model.Resource +import dev.firezone.android.tunnel.model.Site import dev.firezone.android.tunnel.model.isInternetResource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -41,6 +39,7 @@ import kotlinx.coroutines.channels.produce import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select +import uniffi.connlib.DeviceInfo import uniffi.connlib.Event import uniffi.connlib.ProtectSocket import uniffi.connlib.Session @@ -49,10 +48,6 @@ import java.nio.file.Files import java.nio.file.Paths import javax.inject.Inject -data class DeviceInfo( - var firebaseInstallationId: String? = null, -) - @AndroidEntryPoint @OptIn(ExperimentalStdlibApi::class) class TunnelService : VpnService() { @@ -254,7 +249,7 @@ class TunnelService : VpnService() { } fun setDns(dnsList: List) { - sendTunnelCommand(TunnelCommand.SetDns(Gson().toJson(dnsList))) + sendTunnelCommand(TunnelCommand.SetDns(dnsList)) } fun reset() { @@ -270,18 +265,20 @@ class TunnelService : VpnService() { tunnelState = State.CONNECTING updateStatusNotification(TunnelStatusNotification.Connecting) - val deviceInfo = DeviceInfo() - runCatching { Tasks.await(FirebaseInstallations.getInstance().id) } - .onSuccess { firebaseInstallationId -> - deviceInfo.firebaseInstallationId = firebaseInstallationId - }.onFailure { exception -> - Log.d(TAG, "Failed to obtain firebase installation id: $exception") - } + val firebaseInstallationId = + runCatching { Tasks.await(FirebaseInstallations.getInstance().id) } + .getOrElse { exception -> + Log.d(TAG, "Failed to obtain firebase installation id: $exception") + null + } - val gson: Gson = - GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create() + val deviceInfo = + DeviceInfo( + firebaseInstallationId = firebaseInstallationId, + deviceUuid = null, + deviceSerial = null, + identifierForVendor = null, + ) commandChannel = Channel(Channel.UNLIMITED) @@ -298,7 +295,7 @@ class TunnelService : VpnService() { logFilter = config.logFilter, isInternetResourceActive = resourceState.isEnabled(), protectSocket = protectSocket, - deviceInfo = gson.toJson(deviceInfo), + deviceInfo = deviceInfo, ).use { session -> startNetworkMonitoring() startDisconnectMonitoring() @@ -447,7 +444,7 @@ class TunnelService : VpnService() { ) : TunnelCommand() data class SetDns( - val dnsServers: String, + val dnsServers: List, ) : TunnelCommand() data class SetLogDirectives( @@ -506,27 +503,25 @@ class TunnelService : VpnService() { eventChannel.onReceive { event -> when (event) { is Event.ResourcesUpdated -> { - tunnelResources = - moshi.adapter>().fromJson(event.resources)!! + tunnelResources = event.resources.map { convertResource(it) } resourcesUpdated() } is Event.TunInterfaceUpdated -> { - tunnelDnsAddresses = - moshi.adapter>().fromJson(event.dns)!! + tunnelDnsAddresses = event.dns.toMutableList() tunnelSearchDomain = event.searchDomain tunnelIpv4Address = event.ipv4 tunnelIpv6Address = event.ipv6 tunnelRoutes.clear() tunnelRoutes.addAll( - moshi - .adapter>() - .fromJson(event.ipv4Routes)!!, + event.ipv4Routes.map { cidr -> + Cidr(address = cidr.address, prefix = cidr.prefix.toInt()) + }, ) tunnelRoutes.addAll( - moshi - .adapter>() - .fromJson(event.ipv6Routes)!!, + event.ipv6Routes.map { cidr -> + Cidr(address = cidr.address, prefix = cidr.prefix.toInt()) + }, ) buildVpnService() } @@ -554,6 +549,56 @@ class TunnelService : VpnService() { } } + private fun convertResource(resource: uniffi.connlib.Resource): Resource = + when (resource) { + is uniffi.connlib.Resource.Dns -> { + Resource( + type = dev.firezone.android.tunnel.model.ResourceType.DNS, + id = resource.resource.id, + address = resource.resource.address, + addressDescription = resource.resource.addressDescription, + sites = resource.resource.sites.map { convertSite(it) }, + name = resource.resource.name, + status = convertResourceStatus(resource.resource.status), + ) + } + is uniffi.connlib.Resource.Cidr -> { + Resource( + type = dev.firezone.android.tunnel.model.ResourceType.CIDR, + id = resource.resource.id, + address = resource.resource.address, + addressDescription = resource.resource.addressDescription, + sites = resource.resource.sites.map { convertSite(it) }, + name = resource.resource.name, + status = convertResourceStatus(resource.resource.status), + ) + } + is uniffi.connlib.Resource.Internet -> { + Resource( + type = dev.firezone.android.tunnel.model.ResourceType.Internet, + id = resource.resource.id, + address = null, + addressDescription = null, + sites = resource.resource.sites.map { convertSite(it) }, + name = resource.resource.name, + status = convertResourceStatus(resource.resource.status), + ) + } + } + + private fun convertSite(site: uniffi.connlib.Site): dev.firezone.android.tunnel.model.Site = + dev.firezone.android.tunnel.model.Site( + id = site.id, + name = site.name, + ) + + private fun convertResourceStatus(status: uniffi.connlib.ResourceStatus): dev.firezone.android.tunnel.model.StatusEnum = + when (status) { + uniffi.connlib.ResourceStatus.UNKNOWN -> dev.firezone.android.tunnel.model.StatusEnum.UNKNOWN + uniffi.connlib.ResourceStatus.ONLINE -> dev.firezone.android.tunnel.model.StatusEnum.ONLINE + uniffi.connlib.ResourceStatus.OFFLINE -> dev.firezone.android.tunnel.model.StatusEnum.OFFLINE + } + companion object { enum class State { CONNECTING, diff --git a/rust/client-ffi/src/lib.rs b/rust/client-ffi/src/lib.rs index 223432423..b9d4e2df2 100644 --- a/rust/client-ffi/src/lib.rs +++ b/rust/client-ffi/src/lib.rs @@ -10,7 +10,6 @@ use std::{ use anyhow::{Context as _, Result}; use backoff::ExponentialBackoffBuilder; -use client_shared::{V4RouteList, V6RouteList}; use firezone_logging::sentry_layer; use firezone_telemetry::{Telemetry, analytics}; use phoenix_channel::{LoginUrl, PhoenixChannel, get_user_agent}; @@ -43,18 +42,89 @@ pub enum CallbackError { #[derive(uniffi::Object, Debug)] pub struct DisconnectError(client_shared::DisconnectError); +/// Represents a CIDR network (address + prefix length). +/// Used for IPv4 and IPv6 route configuration. +#[derive(uniffi::Record)] +pub struct Cidr { + pub address: String, + pub prefix: u8, +} + +/// Device information for telemetry and identification. +#[derive(uniffi::Record)] +pub struct DeviceInfo { + pub firebase_installation_id: Option, + pub device_uuid: Option, + pub device_serial: Option, + pub identifier_for_vendor: Option, +} + +/// Resource status enum +#[derive(uniffi::Enum)] +pub enum ResourceStatus { + Unknown, + Online, + Offline, +} + +/// Site information for a resource +#[derive(uniffi::Record)] +pub struct Site { + pub id: String, + pub name: String, +} + +/// DNS resource view +#[derive(uniffi::Record)] +pub struct DnsResource { + pub id: String, + pub address: String, + pub name: String, + pub address_description: Option, + pub sites: Vec, + pub status: ResourceStatus, +} + +/// CIDR resource view +#[derive(uniffi::Record)] +pub struct CidrResource { + pub id: String, + pub address: String, + pub name: String, + pub address_description: Option, + pub sites: Vec, + pub status: ResourceStatus, +} + +/// Internet resource view +#[derive(uniffi::Record)] +pub struct InternetResource { + pub id: String, + pub name: String, + pub sites: Vec, + pub status: ResourceStatus, +} + +/// Resource view enum +#[derive(uniffi::Enum)] +pub enum Resource { + Dns { resource: DnsResource }, + Cidr { resource: CidrResource }, + Internet { resource: InternetResource }, +} + #[derive(uniffi::Enum)] pub enum Event { TunInterfaceUpdated { ipv4: String, ipv6: String, - dns: String, + dns: Vec, search_domain: Option, - ipv4_routes: String, - ipv6_routes: String, + ipv4_routes: Vec, + ipv6_routes: Vec, }, ResourcesUpdated { - resources: String, + resources: Vec, }, Disconnected { error: Arc, @@ -94,7 +164,7 @@ impl Session { os_version: String, log_dir: String, log_filter: String, - device_info: String, + device_info: DeviceInfo, is_internet_resource_active: bool, protect_socket: Arc, ) -> Result { @@ -135,7 +205,7 @@ impl Session { os_version: Option, log_dir: String, log_filter: String, - device_info: String, + device_info: DeviceInfo, is_internet_resource_active: bool, ) -> Result { // iOS doesn't need socket protection like Android @@ -218,9 +288,12 @@ impl Session { self.inner.set_internet_resource_state(active); } - pub fn set_dns(&self, dns_servers: String) -> Result<(), ConnlibError> { - let dns_servers = - serde_json::from_str(&dns_servers).context("Failed to deserialize DNS servers")?; + pub fn set_dns(&self, dns_servers: Vec) -> Result<(), ConnlibError> { + let dns_servers: Vec = dns_servers + .into_iter() + .map(|s| s.parse()) + .collect::>() + .context("Failed to parse DNS servers")?; self.inner.set_dns(dns_servers); @@ -253,37 +326,50 @@ impl Session { Ok(()) } - pub async fn next_event(&self) -> Result, ConnlibError> { - match self.events.lock().await.next().await { - Some(client_shared::Event::TunInterfaceUpdated(config)) => { - let dns = serde_json::to_string( - &config.dns_by_sentinel.left_values().collect::>(), - ) - .context("Failed to serialize DNS servers")?; - let ipv4_routes = serde_json::to_string(&V4RouteList::new(config.ipv4_routes)) - .context("Failed to serialize IPv4 routes")?; - let ipv6_routes = serde_json::to_string(&V6RouteList::new(config.ipv6_routes)) - .context("Failed to serialize IPv6 routes")?; + pub async fn next_event(&self) -> Option { + match self.events.lock().await.next().await? { + client_shared::Event::TunInterfaceUpdated(config) => { + let dns: Vec = config + .dns_by_sentinel + .left_values() + .map(|ip| ip.to_string()) + .collect(); - Ok(Some(Event::TunInterfaceUpdated { + let ipv4_routes: Vec = config + .ipv4_routes + .into_iter() + .map(|network| Cidr { + address: network.network_address().to_string(), + prefix: network.netmask(), + }) + .collect(); + + let ipv6_routes: Vec = config + .ipv6_routes + .into_iter() + .map(|network| Cidr { + address: network.network_address().to_string(), + prefix: network.netmask(), + }) + .collect(); + + Some(Event::TunInterfaceUpdated { ipv4: config.ip.v4.to_string(), ipv6: config.ip.v6.to_string(), dns, search_domain: config.search_domain.map(|d| d.to_string()), ipv4_routes, ipv6_routes, - })) + }) } - Some(client_shared::Event::ResourcesUpdated(resources)) => { - let resources = serde_json::to_string(&resources) - .context("Failed to serialize resource list")?; + client_shared::Event::ResourcesUpdated(resources) => { + let resources: Vec = resources.into_iter().map(Into::into).collect(); - Ok(Some(Event::ResourcesUpdated { resources })) + Some(Event::ResourcesUpdated { resources }) } - Some(client_shared::Event::Disconnected(error)) => Ok(Some(Event::Disconnected { + client_shared::Event::Disconnected(error) => Some(Event::Disconnected { error: Arc::new(DisconnectError(error)), - })), - None => Ok(None), + }), } } } @@ -317,13 +403,18 @@ fn connect( os_version: Option, log_dir: String, log_filter: String, - device_info: String, + device_info: DeviceInfo, is_internet_resource_active: bool, tcp_socket_factory: Arc>, udp_socket_factory: Arc>, ) -> Result { - let device_info = - serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?; + // Convert FFI DeviceInfo to internal phoenix_channel::DeviceInfo + let device_info = phoenix_channel::DeviceInfo { + device_uuid: device_info.device_uuid, + device_serial: device_info.device_serial, + identifier_for_vendor: device_info.identifier_for_vendor, + firebase_installation_id: device_info.firebase_installation_id, + }; let secret = SecretString::from(token); let runtime = tokio::runtime::Builder::new_multi_thread() @@ -462,6 +553,78 @@ fn install_rustls_crypto_provider() { } } +impl From for Resource { + fn from(resource: connlib_model::ResourceView) -> Self { + match resource { + connlib_model::ResourceView::Dns(dns) => Resource::Dns { + resource: dns.into(), + }, + connlib_model::ResourceView::Cidr(cidr) => Resource::Cidr { + resource: cidr.into(), + }, + connlib_model::ResourceView::Internet(internet) => Resource::Internet { + resource: internet.into(), + }, + } + } +} + +impl From for DnsResource { + fn from(dns: connlib_model::DnsResourceView) -> Self { + DnsResource { + id: dns.id.to_string(), + address: dns.address, + name: dns.name, + address_description: dns.address_description, + sites: dns.sites.into_iter().map(Into::into).collect(), + status: dns.status.into(), + } + } +} + +impl From for CidrResource { + fn from(cidr: connlib_model::CidrResourceView) -> Self { + CidrResource { + id: cidr.id.to_string(), + address: cidr.address.to_string(), + name: cidr.name, + address_description: cidr.address_description, + sites: cidr.sites.into_iter().map(Into::into).collect(), + status: cidr.status.into(), + } + } +} + +impl From for InternetResource { + fn from(internet: connlib_model::InternetResourceView) -> Self { + InternetResource { + id: internet.id.to_string(), + name: internet.name, + sites: internet.sites.into_iter().map(Into::into).collect(), + status: internet.status.into(), + } + } +} + +impl From for Site { + fn from(site: connlib_model::Site) -> Self { + Site { + id: site.id.to_string(), + name: site.name, + } + } +} + +impl From for ResourceStatus { + fn from(status: connlib_model::ResourceStatus) -> Self { + match status { + connlib_model::ResourceStatus::Unknown => ResourceStatus::Unknown, + connlib_model::ResourceStatus::Online => ResourceStatus::Online, + connlib_model::ResourceStatus::Offline => ResourceStatus::Offline, + } + } +} + impl From for ConnlibError { fn from(value: anyhow::Error) -> Self { Self(value) diff --git a/rust/client-shared/src/lib.rs b/rust/client-shared/src/lib.rs index f3b9b26ff..513339ecc 100644 --- a/rust/client-shared/src/lib.rs +++ b/rust/client-shared/src/lib.rs @@ -1,5 +1,4 @@ //! Main connlib library for clients. -pub use crate::serde_routelist::{V4RouteList, V6RouteList}; pub use connlib_model::StaticSecret; pub use eventloop::DisconnectError; pub use firezone_tunnel::TunConfig; @@ -23,7 +22,6 @@ use tokio_stream::wrappers::WatchStream; use tun::Tun; mod eventloop; -mod serde_routelist; const PHOENIX_TOPIC: &str = "client"; diff --git a/rust/client-shared/src/serde_routelist.rs b/rust/client-shared/src/serde_routelist.rs deleted file mode 100644 index 902a61a61..000000000 --- a/rust/client-shared/src/serde_routelist.rs +++ /dev/null @@ -1,46 +0,0 @@ -use ip_network::{Ipv4Network, Ipv6Network}; -use std::net::{Ipv4Addr, Ipv6Addr}; - -#[derive(serde::Serialize, Clone, Copy, Debug)] -struct Cidr { - address: T, - prefix: u8, -} - -/// Custom adaptor for a different serialisation format for the Apple and Android clients. -#[derive(serde::Serialize)] -#[serde(transparent)] -pub struct V4RouteList(Vec>); - -impl V4RouteList { - pub fn new(route: impl IntoIterator) -> Self { - Self( - route - .into_iter() - .map(|n| Cidr { - address: n.network_address(), - prefix: n.netmask(), - }) - .collect(), - ) - } -} - -/// Custom adaptor for a different serialisation format for the Apple and Android clients. -#[derive(serde::Serialize)] -#[serde(transparent)] -pub struct V6RouteList(Vec>); - -impl V6RouteList { - pub fn new(route: impl IntoIterator) -> Self { - Self( - route - .into_iter() - .map(|n| Cidr { - address: n.network_address(), - prefix: n.netmask(), - }) - .collect(), - ) - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift index 31b7621ca..ba22f3c46 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift @@ -33,39 +33,24 @@ public class DeviceMetadata { } #if os(iOS) - public static func deviceInfo() -> DeviceInfo { - return DeviceInfo(identifierForVendor: UIDevice.current.identifierForVendor!.uuidString) - } - #else - public static func deviceInfo() -> DeviceInfo { - return DeviceInfo(deviceUuid: getDeviceUuid()!, deviceSerial: getDeviceSerial()!) + public static func getIdentifierForVendor() -> String? { + return UIDevice.current.identifierForVendor?.uuidString } #endif } -#if os(iOS) - public struct DeviceInfo: Encodable { - let identifierForVendor: String - } -#endif - #if os(macOS) import IOKit - public struct DeviceInfo: Encodable { - let deviceUuid: String - let deviceSerial: String - } - - func getDeviceUuid() -> String? { + public func getDeviceUuid() -> String? { return getDeviceInfo(key: kIOPlatformUUIDKey as CFString) } - func getDeviceSerial() -> String? { + public func getDeviceSerial() -> String? { return getDeviceInfo(key: kIOPlatformSerialNumberKey as CFString) } - func getDeviceInfo(key: CFString) -> String? { + private func getDeviceInfo(key: CFString) -> String? { let matchingDict = IOServiceMatching("IOPlatformExpertDevice") let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, matchingDict) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index 346c0d1d8..6ea2dea8b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -109,11 +109,8 @@ class IPCClient { // Save hash to compare against self.resourceListHash = Data(SHA256.hash(data: data)) - let jsonDecoder = JSONDecoder() - jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase - do { - let decoded = try jsonDecoder.decode([Resource].self, from: data) + let decoded = try self.decoder.decode([Resource].self, from: data) self.resourcesListCache = ResourceList.loaded(decoded) continuation.resume(returning: self.resourcesListCache) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift index 2239b4cba..f39bd73ef 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift @@ -27,7 +27,7 @@ public enum ResourceList { } } -public struct Resource: Decodable, Identifiable, Equatable { +public struct Resource: Codable, Identifiable, Equatable { public let id: String public var name: String public var address: String? @@ -59,7 +59,7 @@ public struct Resource: Decodable, Identifiable, Equatable { } } -public enum ResourceStatus: String, Decodable { +public enum ResourceStatus: String, Codable { case offline = "Offline" case online = "Online" case unknown = "Unknown" @@ -91,7 +91,7 @@ public enum ResourceStatus: String, Decodable { } } -public enum ResourceType: String, Decodable { +public enum ResourceType: String, Codable { case dns case cidr case ip diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Site.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Site.swift index f72c3d72f..84d6a41bc 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Site.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Site.swift @@ -7,7 +7,7 @@ import Foundation -public struct Site: Decodable, Identifiable, Equatable { +public struct Site: Codable, Identifiable, Equatable { public let id: String public var name: String diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 81a8907e2..83ea2626f 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -140,7 +140,7 @@ class Adapter: @unchecked Sendable { private var internetResourceEnabled: Bool /// Keep track of resources for UI - private var resourceListJSON: String? + private var resources: [Resource]? /// Starting parameters private let apiURL: String @@ -180,17 +180,30 @@ class Adapter: @unchecked Sendable { 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 deviceInfo = try JSONEncoder().encode(DeviceMetadata.deviceInfo()) - let deviceInfoStr = String(data: deviceInfo, encoding: .utf8) ?? "{}" 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 { @@ -203,7 +216,7 @@ class Adapter: @unchecked Sendable { osVersion: osVersion, logDir: logDir, logFilter: logFilter, - deviceInfo: deviceInfoStr, + deviceInfo: deviceInfo, isInternetResourceActive: internetResourceEnabled ) } catch { @@ -277,17 +290,31 @@ class Adapter: @unchecked Sendable { /// Get the current set of resources in the completionHandler, only returning /// them if the resource list has changed. func getResourcesIfVersionDifferentFrom( - hash: Data, completionHandler: @escaping (String?) -> Void + 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 } - if hash == Data(SHA256.hash(data: Data((resourceListJSON ?? "").utf8))) { + // 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(resourceListJSON) + completionHandler(propertyListData) } } } @@ -317,19 +344,13 @@ class Adapter: @unchecked Sendable { let ipv4, let ipv6, let dns, let searchDomain, let ipv4Routes, let ipv6Routes): Log.log("Received TunInterfaceUpdated event") - // Decode all data into local variables first to ensure all parsing succeeds before applying - guard let dnsData = dns.data(using: .utf8), - let dnsAddresses = try? JSONDecoder().decode([String].self, from: dnsData), - let data4 = ipv4Routes.data(using: .utf8), - let data6 = ipv6Routes.data(using: .utf8), - let decoded4 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data4), - let decoded6 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data6) - else { - fatalError("Could not decode network configuration from connlib") + // 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 } - - let routes4 = decoded4.compactMap({ $0.asNEIPv4Route }) - let routes6 = decoded6.compactMap({ $0.asNEIPv6Route }) // All decoding succeeded - now apply settings atomically guard let provider = packetTunnelProvider else { @@ -342,7 +363,7 @@ class Adapter: @unchecked Sendable { let networkSettings = NetworkSettings(packetTunnelProvider: provider) networkSettings.tunnelAddressIPv4 = ipv4 networkSettings.tunnelAddressIPv6 = ipv6 - networkSettings.dnsAddresses = dnsAddresses + networkSettings.dnsAddresses = dns networkSettings.routes4 = routes4 networkSettings.routes6 = routes6 networkSettings.setSearchDomain(domain: searchDomain) @@ -350,13 +371,13 @@ class Adapter: @unchecked Sendable { networkSettings.apply() - case .resourcesUpdated(let resources): - Log.log("Received ResourcesUpdated event with \(resources.count) bytes") + 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.resourceListJSON = resources + self.resources = resourceList } // Apply network settings to flush DNS cache when resources change @@ -455,23 +476,70 @@ class Adapter: @unchecked Sendable { Log.warning("IP address \(stringAddress) did not parse as either IPv4 or IPv6") } - // Step 3: Encode - guard let encoded = try? JSONEncoder().encode(parsedResolvers), - let jsonResolvers = String(data: encoded, encoding: .utf8) - else { - Log.warning("jsonResolvers conversion failed: \(parsedResolvers)") - return - } - - // Step 4: Send to connlib - Log.log("Sending resolvers to connlib: \(jsonResolvers)") - sendCommand(.setDns(jsonResolvers)) + // 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 diff --git a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift index f28116d1f..819090fd9 100644 --- a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift +++ b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift @@ -127,19 +127,25 @@ enum IPv4SubnetMaskLookup { ] } -// Route convenience helpers. Data is from connlib and guaranteed to be valid. -// Otherwise, we should crash and learn about it. +// Route convenience helpers. extension NetworkSettings { - struct Cidr: Codable { + struct Cidr { let address: String let prefix: Int - var asNEIPv4Route: NEIPv4Route { - return NEIPv4Route( - destinationAddress: address, subnetMask: IPv4SubnetMaskLookup.table[prefix]!) + var asNEIPv4Route: NEIPv4Route? { + guard let subnetMask = IPv4SubnetMaskLookup.table[prefix] else { + Log.warning("Invalid IPv4 prefix: \(prefix) for address: \(address)") + return nil + } + return NEIPv4Route(destinationAddress: address, subnetMask: subnetMask) } - var asNEIPv6Route: NEIPv6Route { + var asNEIPv6Route: NEIPv6Route? { + guard prefix >= 0 && prefix <= 128 else { + Log.warning("Invalid IPv6 prefix: \(prefix) for address: \(address)") + return nil + } return NEIPv6Route(destinationAddress: address, networkPrefixLength: NSNumber(value: prefix)) } } diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index ae0b75f5e..9598aadfc 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -183,8 +183,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } // Use hash comparison to only return resources if they've changed - adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in - completionHandler?(resourceListJSON?.data(using: .utf8)) + adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceData in + completionHandler?(resourceData) } case .clearLogs: clearLogs(completionHandler) diff --git a/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift b/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift index d8931e39e..4582078cd 100644 --- a/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift +++ b/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift @@ -5,7 +5,7 @@ import Foundation enum SessionCommand { case disconnect case setInternetResourceState(Bool) - case setDns(String) + case setDns([String]) case reset(String) } @@ -24,20 +24,14 @@ func runSessionEventLoop( // Event polling task - polls Rust for events and sends to eventSender group.addTask { while !Task.isCancelled { - do { - // Poll for next event from Rust - guard let event = try await session.nextEvent() else { - // No event returned - session has ended - Log.log("SessionEventLoop: Event stream ended, exiting event loop") - break - } - - eventSender.send(event) - } catch { - Log.error(error) - Log.log("SessionEventLoop: Error polling event, continuing") - continue + // Poll for next event from Rust + guard let event = await session.nextEvent() else { + // No event returned - session has ended + Log.log("SessionEventLoop: Event stream ended, exiting event loop") + break } + + eventSender.send(event) } Log.log("SessionEventLoop: Event polling finished")