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

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