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 <thomas@eizinger.io>
This commit is contained in:
Mariusz Klochowicz
2025-10-16 15:45:31 +10:30
committed by GitHub
parent 97f3979fa6
commit e76daaaab3
13 changed files with 409 additions and 199 deletions

View File

@@ -19,7 +19,7 @@ spotless {
format("misc") {
target("*.gradle", "*.md", ".gitignore")
trimTrailingWhitespace()
indentWithSpaces()
leadingTabsToSpaces()
endWithNewline()
}
kotlin {

View File

@@ -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<String>) {
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<TunnelCommand>(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<String>,
) : TunnelCommand()
data class SetLogDirectives(
@@ -506,27 +503,25 @@ class TunnelService : VpnService() {
eventChannel.onReceive { event ->
when (event) {
is Event.ResourcesUpdated -> {
tunnelResources =
moshi.adapter<List<Resource>>().fromJson(event.resources)!!
tunnelResources = event.resources.map { convertResource(it) }
resourcesUpdated()
}
is Event.TunInterfaceUpdated -> {
tunnelDnsAddresses =
moshi.adapter<MutableList<String>>().fromJson(event.dns)!!
tunnelDnsAddresses = event.dns.toMutableList()
tunnelSearchDomain = event.searchDomain
tunnelIpv4Address = event.ipv4
tunnelIpv6Address = event.ipv6
tunnelRoutes.clear()
tunnelRoutes.addAll(
moshi
.adapter<MutableList<Cidr>>()
.fromJson(event.ipv4Routes)!!,
event.ipv4Routes.map { cidr ->
Cidr(address = cidr.address, prefix = cidr.prefix.toInt())
},
)
tunnelRoutes.addAll(
moshi
.adapter<MutableList<Cidr>>()
.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,

View File

@@ -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<String>,
pub device_uuid: Option<String>,
pub device_serial: Option<String>,
pub identifier_for_vendor: Option<String>,
}
/// 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<String>,
pub sites: Vec<Site>,
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<String>,
pub sites: Vec<Site>,
pub status: ResourceStatus,
}
/// Internet resource view
#[derive(uniffi::Record)]
pub struct InternetResource {
pub id: String,
pub name: String,
pub sites: Vec<Site>,
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<String>,
search_domain: Option<String>,
ipv4_routes: String,
ipv6_routes: String,
ipv4_routes: Vec<Cidr>,
ipv6_routes: Vec<Cidr>,
},
ResourcesUpdated {
resources: String,
resources: Vec<Resource>,
},
Disconnected {
error: Arc<DisconnectError>,
@@ -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<dyn ProtectSocket>,
) -> Result<Self, ConnlibError> {
@@ -135,7 +205,7 @@ impl Session {
os_version: Option<String>,
log_dir: String,
log_filter: String,
device_info: String,
device_info: DeviceInfo,
is_internet_resource_active: bool,
) -> Result<Self, ConnlibError> {
// 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<String>) -> Result<(), ConnlibError> {
let dns_servers: Vec<std::net::IpAddr> = dns_servers
.into_iter()
.map(|s| s.parse())
.collect::<Result<_, _>>()
.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<Option<Event>, 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::<Vec<_>>(),
)
.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<Event> {
match self.events.lock().await.next().await? {
client_shared::Event::TunInterfaceUpdated(config) => {
let dns: Vec<String> = config
.dns_by_sentinel
.left_values()
.map(|ip| ip.to_string())
.collect();
Ok(Some(Event::TunInterfaceUpdated {
let ipv4_routes: Vec<Cidr> = config
.ipv4_routes
.into_iter()
.map(|network| Cidr {
address: network.network_address().to_string(),
prefix: network.netmask(),
})
.collect();
let ipv6_routes: Vec<Cidr> = 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<Resource> = 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<String>,
log_dir: String,
log_filter: String,
device_info: String,
device_info: DeviceInfo,
is_internet_resource_active: bool,
tcp_socket_factory: Arc<dyn SocketFactory<TcpSocket>>,
udp_socket_factory: Arc<dyn SocketFactory<UdpSocket>>,
) -> Result<Session, ConnlibError> {
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<connlib_model::ResourceView> 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<connlib_model::DnsResourceView> 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<connlib_model::CidrResourceView> 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<connlib_model::InternetResourceView> 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<connlib_model::Site> for Site {
fn from(site: connlib_model::Site) -> Self {
Site {
id: site.id.to_string(),
name: site.name,
}
}
}
impl From<connlib_model::ResourceStatus> 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<anyhow::Error> for ConnlibError {
fn from(value: anyhow::Error) -> Self {
Self(value)

View File

@@ -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";

View File

@@ -1,46 +0,0 @@
use ip_network::{Ipv4Network, Ipv6Network};
use std::net::{Ipv4Addr, Ipv6Addr};
#[derive(serde::Serialize, Clone, Copy, Debug)]
struct Cidr<T> {
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<Cidr<Ipv4Addr>>);
impl V4RouteList {
pub fn new(route: impl IntoIterator<Item = Ipv4Network>) -> 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<Cidr<Ipv6Addr>>);
impl V6RouteList {
pub fn new(route: impl IntoIterator<Item = Ipv6Network>) -> Self {
Self(
route
.into_iter()
.map(|n| Cidr {
address: n.network_address(),
prefix: n.netmask(),
})
.collect(),
)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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")