Files
ios-client/NetbirdKit/ProfileConnectionCache.swift
T
Zoltan Papp fef31c66fd Drop the dedicated server URL file (#202)
* refactor(ios): own profile management in the Go core

Replace the parallel Swift profile manager with a thin wrapper over the
core's ID-based profilemanager.ServiceManager (via the new NetBirdSDK iOS
binding), eliminating the duplicate implementation. Profiles are now
ID-keyed (on-disk filename = id, display name lives in the config); the
default profile stays "default" at the container root and never becomes a
hex id, matching the Go/desktop semantics.

- ProfileManager: thin wrapper over NetBirdSDKProfileManager on iOS; tvOS
  keeps its single-default, container-root behavior (no Go profile manager)
- ProfileLayoutMigration: one-time, idempotent, file-coordinated migration
  from the legacy profiles/<name>/ directory layout to the Go layout;
  preserves auth tokens (copies unparseable configs verbatim), keeps the
  active selection and logged-out profiles, and only marks itself done on
  full success so a failure retries on the next launch
- ProfileConnectionCache and all callers re-keyed by profile id
- Settings/server changes already target the active profile through the
  profile manager (Preferences.configFile -> activeConfigPath)
- Tests: NetBirdTests/ProfileMigrationTests covers the migration, reads it
  back through the Go lib, and round-trips a setting via NetBirdSDKPreferences
  (the test target now links NetBirdSDK)
- Bump netbird-core submodule to include the iOS profile manager binding

* ci: re-enable Test workflow on push and pull_request

Run the iOS test suite on PRs again to check whether the previously reported test crash still reproduces.

* fix(ios): call gomobile path getters with NSErrorPointer

The non-null String path getters (getActiveConfigPath/getActiveStateFilePath/getConfigPath/getStateFilePath) are generated by gomobile as value-returning calls with an explicit NSErrorPointer parameter, not throwing methods. Wrap them via a goPath helper in ProfileManager (and matching helpers in the tests), fixing the iOS app build failure (missing argument for parameter).

* fix(ios): raise NetBirdTests deployment target to iOS 15.0

The NetBirdTests target was iOS 14.0 while the NetBird app module requires iOS 15.0, so `@testable import NetBird` failed to build ("Compiling for iOS 14.0, but module 'NetBird' has a minimum deployment target of iOS 15.0"). Match the test target to the app so the test bundle compiles and the suite can run.

* ci(ios): use valid GOOGLE_APP_ID in dummy Firebase plist

The dummy GoogleService-Info.plist used a GOOGLE_APP_ID of "dummy",
which fails Firebase's app-ID format validation. At launch the host app
calls FirebaseApp.configure(options:), and +[FIRApp addAppToAppDictionary:]
raised an uncaught NSException, aborting the host before the test runner
could establish a connection ("Test crashed with signal abrt before
establishing connection"). This blocked all hosted tests, not just the
new profile-migration ones.

Give the dummy app ID a well-formed value so configure() succeeds.

* fix(ios): skip Firebase configuration under unit tests

The host app calls FirebaseApp.configure() at launch. When launched purely
to host unit tests, this aborted the process before the test runner could
connect because FirebaseApp.configure() raises an uncaught ObjC exception on
an invalid app ID (the CI dummy plist).

Gate both the iOS and tvOS configure paths on the XCTestConfigurationFilePath
environment variable so Firebase is never initialized in the test host, and
fold the duplicated configure logic into a single helper.

* test(ios): write ManagementURL as Go-style object in migration fixtures

The legacy netbird.cfg is written by the Go SDK, which serializes
Config.ManagementURL (a url.URL) as a nested {Scheme,Host,Path} object,
not a plain string. The settings round-trip test drives NetBirdSDKPreferences,
which fully unmarshals Config and rejected the string fixture with
"cannot unmarshal string into Go struct field Config.ManagementURL".
Normalize the fixture to the object form the SDK actually produces.

* fix(ios): narrow NetBirdTests FRAMEWORK_SEARCH_PATHS to project dir

The test target used a recursive "$(PROJECT_DIR)/**" framework search path,
scanning every subdirectory (DerivedData, netbird-core, package checkouts) and
risking unexpected framework resolution. NetBirdSDK.xcframework lives at the
project root, so point the search path at "$(PROJECT_DIR)" directly.

* chore(ios): bump netbird-core to preserve profile name on auth save

* feat(ios): exclude profile storage from device backups

The profile configs carry the WireGuard and SSH private keys, and the
WireGuard key is the peer's identity: restoring a backup onto another
device would produce two devices connecting with the same key. Mark the
profiles/ directory and the container-root profile files (netbird.cfg,
state.json, active_profile.json, netbird_server_url) as excluded from
iCloud/iTunes backups.

The exclusion is re-applied on every launch from the ProfileManager init:
the Go core rewrites some of these files via temp-file+rename, which drops
per-file attributes. The profiles/ directory is excluded as a whole (and
created up front if missing), so its contents stay covered regardless of
how they are rewritten. After a restore the user re-logs in, matching how
a profile behaves when moved to a new machine on desktop.

* fix(ios): back the profile connection cache with the App Group suite

The cache was backed by UserDefaults.standard, which is per-process: the
network extension is the process that learns the peer's ip/fqdn and runs
the login, so everything it wrote was invisible to the main app and vice
versa. Back it with the App Group suite instead, so both processes see the
same entries.

Entries written by an earlier version are moved over on first use. A suite
that already holds data is never overwritten, so a stale app-local copy
cannot clobber what the extension has since written.

* fix(ios): drop the dedicated server URL file

The file was a fourth copy of the management URL, written next to the
profile config. It existed because Swift code used to rebuild configs and
could drop the URL, leaving login to fall back to the default cloud server
and write that back — so the URL had to be recoverable from somewhere else.

Both reasons are gone. In the ID-based layout the config survives logout
(only the keys are cleared), and the core's DirectUpdateOrCreateConfig
merges into the existing config instead of rebuilding it, so no write can
lose the URL. The connection cache covers the window before the config can
be read back, and now that it lives in the App Group suite it does so
across processes too — which was the file's last unique property.

managementURL(forID:) is therefore config -> connection cache.
saveServerURL(_:forID:) stays as the callers' entry point but records into
the cache. Profiles migrated from the legacy layout keep their URL:
ProfileLayoutMigration already seeds the cache from the legacy file.

The file name stays in the backup exclusion list — earlier versions left
files behind, and those should not reach a backup either.

* fix(ios): resolve the login IPC URL via ProfileManager

login() read the management URL straight from the connection cache when
building the "Login:<configPath>|<statePath>[|<managementURL>]" message,
bypassing ProfileManager.managementURL(forID:), which resolves from the
profile config first and falls back to the cache.

With an empty cache but a live config the URL was omitted from the IPC
message. The practical impact is small — when the config exists the
extension reads the URL from it anyway — but this was the last call site
reading the cache directly for the URL. Going through ProfileManager
keeps both login paths consistent (performLogin already resolves this
way), re-seeds the cache from the config as a side effect, and keeps the
URL in the message even if a cache entry is ever lost.

* fix(ios): merge the connection cache migration instead of clobbering

The standard → App Group suite migration dropped the legacy copy whenever
the suite already held data, and removed it even when the suite write
never landed. Both lose data on the first launch after the upgrade:

- On-demand VPN can start the extension before the app runs, seeding the
  suite with URL-only entries; the app's entire legacy cache (ip/fqdn and
  URLs for every profile) was then deleted without a merge.
- With a missing App Group entitlement the suite write silently no-ops,
  and the standard-domain copy — the last remaining one — was deleted
  anyway.

Merge instead: suite values win field by field (the suite is strictly
newer than the legacy copy, which is frozen at upgrade time) and legacy
values fill the fields the suite holds no value for. The standard copy is
removed only after a verified read-back of the merged data — compared as
decoded values, since JSON key order is not stable — so a write that did
not land leaves the copy in place and the migration retries on the next
init.

The migration source is now injectable (defaulting to .standard), and
ProfileConnectionCacheTests covers the merge semantics, the failed-write
path, and idempotency. The migration still inherits the unsynchronized
cross-process read-modify-write on the shared blob; that is addressed
separately.

* fix(ios): store the connection cache under per-profile keys

Every cache write was load → mutate → persist on a single JSON blob.
With the cache in the App Group suite that is a cross-process
read-modify-write with no coordination: the app writing ip/fqdn while
the extension seeds a URL means one side's update is lost to the last
writer.

Split the storage so the schema matches the write patterns: one key per
profile for the connection data (ip/fqdn/ipv6, always written together
by the status updates) and one for the management URL (written by the
login paths). Every write becomes a blind single-key set with no prior
read, so concurrent writers cannot clobber each other's fields and no
cross-process locking is needed — file coordination is unsafe from an
extension, where the other process can be suspended while holding a
claim.

The blob is exploded into the per-profile keys on first use: existing
keys are never overwritten (they are strictly newer than the blob), so
concurrent migration from both processes stays benign, and the blob is
removed only after every entry verifiably landed. The standard → suite
merge keeps writing the blob and this second stage explodes it, so the
full upgrade chain is standard blob → suite blob → per-profile keys.

The public API is unchanged. clearConnectionData now removes the
connection-data key instead of writing empty fields — same observable
behavior, one blind delete. remove clears both keys; a process death
between the two can orphan a URL key, which is harmless and cleaned up
by the next remove for that ID.
2026-08-27 09:15:50 +02:00

180 lines
7.6 KiB
Swift

//
// ProfileConnectionCache.swift
// NetBird
//
import Foundation
// MARK: - Model
struct ProfileConnectionEntry: Codable, Equatable {
var ip: String
var fqdn: String
var managementURL: String?
var ipv6: String?
}
// MARK: - Cache
/// Stores and retrieves last-known connection data (ip/fqdn/managementURL) per profile.
///
/// Backed by the App Group suite so the main app and the network extension see
/// the same entries: the extension is the process that learns the peer's ip/fqdn
/// and, on iOS, runs its own login.
///
/// Each profile is stored under two keys — one for the connection data
/// (ip/fqdn/ipv6, always written together by the status updates) and one for
/// the management URL (written by the login paths). The split matches the
/// write patterns, so every write is a blind single-key set with no
/// read-modify-write: concurrent writers in the two processes cannot clobber
/// each other's fields, and no cross-process locking is needed (file
/// coordination is unsafe from an extension — the other process can be
/// suspended while holding a claim).
struct ProfileConnectionCache {
private static let legacyStorageKey = "netbird_profiles_connection_data"
private static let connectionKeyPrefix = "netbird_profile_connection_"
private static let managementURLKeyPrefix = "netbird_profile_mgmt_url_"
private struct ConnectionData: Codable, Equatable {
var ip: String
var fqdn: String
var ipv6: String?
}
private let defaults: UserDefaults
init(defaults: UserDefaults? = nil) {
let store = defaults ?? Preferences.sharedUserDefaults() ?? .standard
ProfileConnectionCache.migrateFromStandardIfNeeded(into: store)
ProfileConnectionCache.migrateBlobToPerProfileKeysIfNeeded(in: store)
self.defaults = store
}
// MARK: - Read
func entry(forID id: String) -> ProfileConnectionEntry? {
let connection = Self.connectionData(forID: id, in: defaults)
let url = defaults.string(forKey: Self.managementURLKey(id))
guard connection != nil || url != nil else { return nil }
return ProfileConnectionEntry(ip: connection?.ip ?? "",
fqdn: connection?.fqdn ?? "",
managementURL: url,
ipv6: connection?.ipv6)
}
func managementURL(forID id: String) -> String? {
return defaults.string(forKey: Self.managementURLKey(id))
}
// MARK: - Write
func save(ip: String, fqdn: String, ipv6: String? = nil, forID id: String) {
guard let data = try? JSONEncoder().encode(ConnectionData(ip: ip, fqdn: fqdn, ipv6: ipv6)) else { return }
defaults.set(data, forKey: Self.connectionKey(id))
}
func saveManagementURL(_ url: String, forID id: String) {
defaults.set(url, forKey: Self.managementURLKey(id))
}
/// Clears ip/fqdn for a profile after logout, preserving managementURL for re-login.
func clearConnectionData(forID id: String) {
defaults.removeObject(forKey: Self.connectionKey(id))
}
/// Removes all cached data for a deleted profile.
func remove(forID id: String) {
defaults.removeObject(forKey: Self.connectionKey(id))
defaults.removeObject(forKey: Self.managementURLKey(id))
}
// MARK: - Private
private static func connectionKey(_ id: String) -> String {
return connectionKeyPrefix + id
}
private static func managementURLKey(_ id: String) -> String {
return managementURLKeyPrefix + id
}
private static func connectionData(forID id: String, in store: UserDefaults) -> ConnectionData? {
guard let data = store.data(forKey: connectionKey(id)) else { return nil }
return try? JSONDecoder().decode(ConnectionData.self, from: data)
}
/// Merges the single-blob cache written by an earlier app version into the
/// App Group suite's blob, which migrateBlobToPerProfileKeysIfNeeded then
/// explodes into the per-profile keys. Suite values win field by field —
/// the suite is strictly newer than the legacy copy, which is frozen at
/// upgrade time — and legacy values only fill fields the suite holds no
/// value for (e.g. ip/fqdn on an entry the extension seeded with just the
/// URL before the app first ran). The standard-domain copy is removed only
/// after a verified read-back of the merged data, so a write that silently
/// fails (e.g. missing App Group entitlement) cannot destroy the last
/// remaining copy; the migration then retries on the next init.
static func migrateFromStandardIfNeeded(from standard: UserDefaults = .standard, into store: UserDefaults) {
guard store != standard else { return }
guard let legacyData = standard.data(forKey: legacyStorageKey) else { return }
let decoder = JSONDecoder()
let legacy = (try? decoder.decode([String: ProfileConnectionEntry].self, from: legacyData)) ?? [:]
let suite = store.data(forKey: legacyStorageKey)
.flatMap { try? decoder.decode([String: ProfileConnectionEntry].self, from: $0) } ?? [:]
var merged = suite
for (id, legacyEntry) in legacy {
if var entry = merged[id] {
if entry.ip.isEmpty { entry.ip = legacyEntry.ip }
if entry.fqdn.isEmpty { entry.fqdn = legacyEntry.fqdn }
if entry.managementURL?.isEmpty != false { entry.managementURL = legacyEntry.managementURL }
if entry.ipv6?.isEmpty != false { entry.ipv6 = legacyEntry.ipv6 }
merged[id] = entry
} else {
merged[id] = legacyEntry
}
}
guard let mergedData = try? JSONEncoder().encode(merged) else { return }
store.set(mergedData, forKey: legacyStorageKey)
guard let readBack = store.data(forKey: legacyStorageKey),
(try? decoder.decode([String: ProfileConnectionEntry].self, from: readBack)) == merged
else { return }
standard.removeObject(forKey: legacyStorageKey)
}
/// Explodes the single-blob layout into the per-profile keys. Existing keys
/// are never overwritten — they are strictly newer than the blob — so two
/// processes migrating concurrently stay benign. The blob is removed only
/// after a verified read-back of every entry, so a write that silently
/// fails leaves the blob in place and the migration retries on the next
/// init.
static func migrateBlobToPerProfileKeysIfNeeded(in store: UserDefaults) {
guard let blobData = store.data(forKey: legacyStorageKey) else { return }
let decoder = JSONDecoder()
let entries = (try? decoder.decode([String: ProfileConnectionEntry].self, from: blobData)) ?? [:]
for (id, entry) in entries {
if store.data(forKey: connectionKey(id)) == nil,
let data = try? JSONEncoder().encode(ConnectionData(ip: entry.ip, fqdn: entry.fqdn, ipv6: entry.ipv6)) {
store.set(data, forKey: connectionKey(id))
}
if store.string(forKey: managementURLKey(id)) == nil,
let url = entry.managementURL, !url.isEmpty {
store.set(url, forKey: managementURLKey(id))
}
}
let landed = entries.allSatisfy { id, entry in
let connectionLanded = connectionData(forID: id, in: store) != nil
let urlLanded = entry.managementURL?.isEmpty != false
|| store.string(forKey: managementURLKey(id)) != nil
return connectionLanded && urlLanded
}
guard landed else { return }
store.removeObject(forKey: legacyStorageKey)
}
}