Files
evgeniyChepelev 0327624b30 Multi-profiles (#80)
* fix layout on ipad

* Create multi profiles

* multi-profile server config, connection cache, and auth fixes

 Split ProfilesView into ProfilesListView, AddProfileSheet, ProfileBadge, AddProfileViewModel
- Add server URL + setup key fields to Add Profile screen
- Store per-profile connection data (ip/fqdn/managementURL) as typed model in ProfileConnectionCache
- Show cached connection info immediately on profile switch; empty if no prior data
- Fix profile deletion persistence via tombstone in profiles.json
- Fix logout to remove both netbird.cfg and state.json (cfg holds auth tokens)
- Preserve managementURL in UI after logout via cache fallback
- Guard polling from overwriting new profile's data during disconnect/reconnect cycle

* fix(multi-profile): reinitialize VPN adapter on profile switch and show current server URL

* Update project.pbxproj

* fix(multi-profile): prevent default server overwrite on logout/re-login

* Update PacketTunnelProvider.swift

* fix(multi-profile): address code review findings

- Normalize only scheme and host to lowercase when saving management
  server URL; previously the full URL was lowercased which could corrupt
  case-sensitive paths on self-hosted servers
- Move switchConnectionInfo(to:) inside the do-block so the connection
  UI is only updated after a successful profile switch
- Add ProfileConnectionCache.remove(for:) and clearConnectionData(for:)
  to prevent stale ip/fqdn/managementURL from persisting after a profile
  is deleted or logged out; call them from removeProfile and logoutProfile
- Use ASWebAuthenticationSession.Callback.customScheme on iOS 17.4+,
  falling back to the deprecated callbackURLScheme initializer on older
  versions; add a comment explaining why "http" is used and that a
  proper fix requires custom-scheme support in the SDK
- Guard presentationAnchor against a missing key window with
  assertionFailure in debug builds instead of silently returning an
  empty UIWindow
2026-04-22 10:06:51 +02:00

407 lines
16 KiB
Swift

//
// ProfileManager.swift
// NetBird
//
// Native Swift implementation of multi-profile management.
// Mirrors the Go ProfileManager logic: each profile is a subdirectory
// containing its own netbird.cfg and state.json files.
//
import Foundation
// MARK: - Profile Model
struct Profile: Identifiable, Equatable {
let name: String
let isActive: Bool
var id: String { name }
static func == (lhs: Profile, rhs: Profile) -> Bool {
lhs.name == rhs.name
}
}
// MARK: - ProfileManager
/// Manages multiple VPN profiles, each with its own config/state files.
///
/// Directory layout inside the App Group container:
/// ```
/// profiles/
/// profiles.json stores active profile name
/// default/
/// netbird.cfg
/// state.json
/// work/
/// netbird.cfg
/// state.json
/// ```
///
/// The "default" profile always exists. Legacy (pre-profile) config files
/// at the container root are migrated into `profiles/default/` on first use.
class ProfileManager {
static let shared = ProfileManager()
private let fileManager = FileManager.default
/// Name validation: only letters, digits, underscore, hyphen (matches Go client).
private static let validNamePattern = "^[a-zA-Z0-9_-]+$"
private let defaultProfileName = "default"
private let profilesDirName = "profiles"
private let metaFileName = "profiles.json"
// MARK: - Init
private init() {
ensureProfilesDirectory()
migrateIfNeeded()
}
// MARK: - Public API
/// Returns all profiles with their active status.
func listProfiles() -> [Profile] {
let meta = readMeta()
let activeProfile = meta?.activeProfile.isEmpty == false ? meta!.activeProfile : defaultProfileName
let deletedSet = Set(meta?.deletedProfiles ?? [])
// Retry deletion of any directories still present after a previous attempt.
for name in deletedSet {
if let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) {
try? fileManager.removeItem(atPath: dir)
}
}
guard let profilesDir = profilesDirectory() else { return [] }
do {
let contents = try fileManager.contentsOfDirectory(atPath: profilesDir)
var profiles: [Profile] = []
for name in contents.sorted() {
guard !deletedSet.contains(name) else { continue }
let fullPath = (profilesDir as NSString).appendingPathComponent(name)
var isDir: ObjCBool = false
if fileManager.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue {
profiles.append(Profile(name: name, isActive: name == activeProfile))
}
}
// Ensure default always appears even if directory listing is empty
if !profiles.contains(where: { $0.name == defaultProfileName }) {
profiles.insert(Profile(name: defaultProfileName, isActive: defaultProfileName == activeProfile), at: 0)
}
return profiles
} catch {
AppLogger.shared.log("ProfileManager: Failed to list profiles: \(error)")
return [Profile(name: defaultProfileName, isActive: true)]
}
}
/// Name of the currently active profile.
func getActiveProfileName() -> String {
guard let meta = readMeta() else { return defaultProfileName }
return meta.activeProfile.isEmpty ? defaultProfileName : meta.activeProfile
}
/// Adds a new profile. Throws if the name is invalid or already exists.
func addProfile(_ name: String) throws {
let sanitized = sanitizeName(name)
guard isValidName(sanitized) else {
throw ProfileError.invalidName(sanitized)
}
guard let dir = profileDirectory(for: sanitized) else {
throw ProfileError.containerUnavailable
}
// If the profile was previously deleted but SDK goroutines recreated its directory,
// remove the stale directory and tombstone so the profile can be created fresh.
var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName)
if meta.deletedProfiles.contains(sanitized) {
try? fileManager.removeItem(atPath: dir)
meta.deletedProfiles.removeAll { $0 == sanitized }
try? writeMeta(meta)
}
guard !fileManager.fileExists(atPath: dir) else {
throw ProfileError.alreadyExists(sanitized)
}
do {
try fileManager.createDirectory(atPath: dir, withIntermediateDirectories: true)
} catch {
throw ProfileError.fileSystemError(error)
}
}
/// Switches the active profile. The caller must stop VPN before calling this.
func switchProfile(_ name: String) throws {
guard let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) else {
throw ProfileError.notFound(name)
}
var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName)
meta.activeProfile = name
try writeMeta(meta)
}
/// Removes a profile. Cannot remove "default" or the currently active profile.
func removeProfile(_ name: String) throws {
guard name != defaultProfileName else {
throw ProfileError.cannotRemoveDefault
}
guard name != getActiveProfileName() else {
throw ProfileError.cannotRemoveActive
}
guard let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) else {
throw ProfileError.notFound(name)
}
// Persist the tombstone BEFORE deleting the directory.
// The Go SDK may recreate the directory via background goroutines; the tombstone
// ensures the profile stays hidden in listProfiles() even if that happens.
var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName)
if !meta.deletedProfiles.contains(name) {
meta.deletedProfiles.append(name)
try writeMeta(meta)
}
try fileManager.removeItem(atPath: dir)
ProfileConnectionCache().remove(for: name)
}
/// Clears authentication data for a profile by removing its config and state files.
/// Both files must be removed: state.json holds runtime state,
/// netbird.cfg holds the auth tokens removing only one is insufficient.
func logoutProfile(_ name: String) throws {
guard let dir = profileDirectory(for: name) else {
throw ProfileError.containerUnavailable
}
let statePath = (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName)
let configPath = (dir as NSString).appendingPathComponent(GlobalConstants.configFileName)
// Preserve the management URL for re-login, but clear stale connection data.
let cache = ProfileConnectionCache()
if let url = managementURL(for: name) {
cache.saveManagementURL(url, for: name)
}
cache.clearConnectionData(for: name)
if fileManager.fileExists(atPath: statePath) {
try fileManager.removeItem(atPath: statePath)
}
if fileManager.fileExists(atPath: configPath) {
try fileManager.removeItem(atPath: configPath)
}
}
// MARK: - Path Accessors
/// Config file path for the active profile.
func activeConfigPath() -> String? {
guard let dir = profileDirectory(for: getActiveProfileName()) else { return nil }
return (dir as NSString).appendingPathComponent(GlobalConstants.configFileName)
}
/// State file path for the active profile.
func activeStatePath() -> String? {
guard let dir = profileDirectory(for: getActiveProfileName()) else { return nil }
return (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName)
}
/// Config file path for a specific profile.
func configPath(for profile: String) -> String? {
guard let dir = profileDirectory(for: profile) else { return nil }
return (dir as NSString).appendingPathComponent(GlobalConstants.configFileName)
}
/// State file path for a specific profile.
func statePath(for profile: String) -> String? {
guard let dir = profileDirectory(for: profile) else { return nil }
return (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName)
}
/// Returns the management URL for a specific profile.
/// Reads from netbird.cfg first; falls back to the dedicated server URL file,
/// then ProfileConnectionCache as last resort.
func managementURL(for profile: String) -> String? {
if let cfgPath = configPath(for: profile),
fileManager.fileExists(atPath: cfgPath),
let data = fileManager.contents(atPath: cfgPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// ManagementURL can be a string or a nested object with Scheme/Host/Path
var urlFromFile: String?
if let urlString = json["ManagementURL"] as? String {
urlFromFile = urlString
} else if let urlObj = json["ManagementURL"] as? [String: Any],
let scheme = urlObj["Scheme"] as? String,
let host = urlObj["Host"] as? String {
let path = urlObj["Path"] as? String ?? ""
urlFromFile = "\(scheme)://\(host)\(path)"
}
if let url = urlFromFile {
// Persist to dedicated file and cache so it survives logout
saveServerURL(url, for: profile)
ProfileConnectionCache().saveManagementURL(url, for: profile)
return url
}
}
// Config missing (e.g. after logout) try dedicated server URL file first
if let url = savedServerURL(for: profile) {
return url
}
return ProfileConnectionCache().managementURL(for: profile)
}
/// Saves the management URL to a dedicated file inside the profile directory.
/// This file is NOT deleted by logoutProfile(), so it survives logout.
func saveServerURL(_ url: String, for profile: String) {
guard let dir = profileDirectory(for: profile) else { return }
let filePath = (dir as NSString).appendingPathComponent(GlobalConstants.serverURLFileName)
try? url.write(toFile: filePath, atomically: true, encoding: .utf8)
}
/// Reads the management URL from the dedicated server URL file.
func savedServerURL(for profile: String) -> String? {
guard let dir = profileDirectory(for: profile) else { return nil }
let filePath = (dir as NSString).appendingPathComponent(GlobalConstants.serverURLFileName)
guard fileManager.fileExists(atPath: filePath),
let url = try? String(contentsOfFile: filePath, encoding: .utf8),
!url.isEmpty else { return nil }
return url
}
// MARK: - Private Helpers
private func containerURL() -> URL? {
fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName)
}
private func profilesDirectory() -> String? {
guard let container = containerURL() else { return nil }
return container.appendingPathComponent(profilesDirName).path
}
private func profileDirectory(for name: String) -> String? {
guard let profilesDir = profilesDirectory() else { return nil }
return (profilesDir as NSString).appendingPathComponent(name)
}
private func metaFilePath() -> String? {
guard let profilesDir = profilesDirectory() else { return nil }
return (profilesDir as NSString).appendingPathComponent(metaFileName)
}
private func ensureProfilesDirectory() {
guard let profilesDir = profilesDirectory() else { return }
if !fileManager.fileExists(atPath: profilesDir) {
try? fileManager.createDirectory(atPath: profilesDir, withIntermediateDirectories: true)
}
// Ensure default profile directory exists
guard let defaultDir = profileDirectory(for: defaultProfileName) else { return }
if !fileManager.fileExists(atPath: defaultDir) {
try? fileManager.createDirectory(atPath: defaultDir, withIntermediateDirectories: true)
}
}
/// Migrates legacy config/state from the container root into profiles/default/.
private func migrateIfNeeded() {
guard let container = containerURL() else { return }
guard let defaultDir = profileDirectory(for: defaultProfileName) else { return }
let legacyConfig = container.appendingPathComponent(GlobalConstants.configFileName).path
let legacyState = container.appendingPathComponent(GlobalConstants.stateFileName).path
let newConfig = (defaultDir as NSString).appendingPathComponent(GlobalConstants.configFileName)
let newState = (defaultDir as NSString).appendingPathComponent(GlobalConstants.stateFileName)
// Only migrate if legacy files exist and new ones don't
if fileManager.fileExists(atPath: legacyConfig) && !fileManager.fileExists(atPath: newConfig) {
try? fileManager.copyItem(atPath: legacyConfig, toPath: newConfig)
AppLogger.shared.log("ProfileManager: Migrated legacy config to default profile")
}
if fileManager.fileExists(atPath: legacyState) && !fileManager.fileExists(atPath: newState) {
try? fileManager.copyItem(atPath: legacyState, toPath: newState)
AppLogger.shared.log("ProfileManager: Migrated legacy state to default profile")
}
// Set default as active if no meta exists
if readMeta() == nil {
try? writeMeta(ProfileMeta(activeProfile: defaultProfileName))
}
}
private func isValidName(_ name: String) -> Bool {
!name.isEmpty && name.range(of: ProfileManager.validNamePattern, options: .regularExpression) != nil
}
private func sanitizeName(_ name: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-"))
return String(name.unicodeScalars.filter { allowed.contains($0) })
}
// MARK: - Meta File (profiles.json)
private struct ProfileMeta: Codable {
var activeProfile: String
/// Profiles pending deletion kept as a tombstone so that directories
/// recreated by SDK background goroutines don't reappear in the list.
var deletedProfiles: [String]
init(activeProfile: String, deletedProfiles: [String] = []) {
self.activeProfile = activeProfile
self.deletedProfiles = deletedProfiles
}
/// Backward-compatible decode: old profiles.json files have no deletedProfiles field.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
activeProfile = try c.decode(String.self, forKey: .activeProfile)
deletedProfiles = (try? c.decode([String].self, forKey: .deletedProfiles)) ?? []
}
}
private func readMeta() -> ProfileMeta? {
guard let path = metaFilePath(),
let data = fileManager.contents(atPath: path) else { return nil }
return try? JSONDecoder().decode(ProfileMeta.self, from: data)
}
private func writeMeta(_ meta: ProfileMeta) throws {
guard let path = metaFilePath() else {
throw ProfileError.containerUnavailable
}
let data = try JSONEncoder().encode(meta)
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
}
}
// MARK: - Errors
enum ProfileError: LocalizedError {
case invalidName(String)
case alreadyExists(String)
case notFound(String)
case cannotRemoveDefault
case cannotRemoveActive
case containerUnavailable
case fileSystemError(Error)
var errorDescription: String? {
switch self {
case .invalidName(let name):
return "Invalid profile name: '\(name)'. Only letters, numbers, underscores and hyphens are allowed."
case .alreadyExists(let name):
return "Profile '\(name)' already exists."
case .notFound(let name):
return "Profile '\(name)' not found."
case .cannotRemoveDefault:
return "Cannot remove the default profile."
case .cannotRemoveActive:
return "Cannot remove the active profile. Switch to another profile first."
case .containerUnavailable:
return "App group container is unavailable."
case .fileSystemError(let error):
return "File system error: \(error.localizedDescription)"
}
}
}