diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index f74ebe2ae..1da2342bc 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; 8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; }; 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; }; + 8DA9BFD12DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */; }; + 8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */; }; 8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; }; 8DC1699D2CFF77D1006801B5 /* dev.firezone.firezone.network-extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8DC169A02CFF77D1006801B5 /* dev.firezone.firezone.network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -108,6 +110,7 @@ 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = ""; }; 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = ""; }; 8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = ""; }; 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; }; 8DC08BD12B297B7B00675F46 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 8DC333F72D2FA85200E627D5 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.2.sdk/usr/lib/libresolv.tbd; sourceTree = DEVELOPER_DIR; }; @@ -171,6 +174,7 @@ 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, + 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */, 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */, @@ -334,7 +338,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1610; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1630; TargetAttributes = { 05CF1CEF290B1CEE00CF4755 = { CreatedOnToolsVersion = 14.0.1; @@ -492,6 +496,7 @@ 8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */, 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */, 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */, + 8DA9BFD12DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */, 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */, 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */, @@ -512,6 +517,7 @@ 8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */, 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */, 8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */, + 8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -739,6 +745,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 47R2M6779T; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -807,6 +814,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 47R2M6779T; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme b/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme index 7b21f9cd2..45810606c 100644 --- a/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme +++ b/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme @@ -1,6 +1,6 @@ Bool { if let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) { @@ -24,7 +24,7 @@ enum BundleHelper { return String(gitSha.prefix(8)) } - static var appGroupId: String { + public static var appGroupId: String { guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else { fatalError("AppGroupIdentifier missing in app's Info.plist") diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index 854f1b684..dbc6ae099 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -8,6 +8,9 @@ import CryptoKit import Foundation import NetworkExtension +// TODO: Use a more abstract IPC protocol to make this less terse +// TODO: Consider making this an actor to guarantee strict ordering + class IPCClient { enum Error: Swift.Error { case invalidNotification @@ -41,17 +44,17 @@ class IPCClient { // return them to callers when they haven't changed. var resourcesListCache: ResourceList = ResourceList.loading + // Cache the configuration on this side of the IPC barrier so we can return it to callers if it hasn't changed. + private var configurationHash = Data() + private var configurationCache: Configuration? + init(session: NETunnelProviderSession) { self.session = session } // Encoder used to send messages to the tunnel - let encoder = { - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - - return encoder - }() + let encoder = PropertyListEncoder() + let decoder = PropertyListDecoder() func start(token: String? = nil) throws { var options: [String: NSObject] = [:] @@ -61,27 +64,69 @@ class IPCClient { options.merge(["token": token as NSObject]) { _, new in new } } - // Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this - // persisted to the app side container URL. - if let id = FirezoneId.load(.pre140) { - options.merge(["id": id as NSObject]) { _, new in new } - } - try session().startTunnel(options: options) } - func signOut() throws { - try session([.connected, .connecting, .reasserting]).stopTunnel() - try session().sendProviderMessage(encoder.encode(ProviderMessage.signOut)) + func signOut() async throws { + try await sendMessageWithoutResponse(ProviderMessage.signOut) } func stop() throws { try session([.connected, .connecting, .reasserting]).stopTunnel() } - func toggleInternetResource(enabled: Bool) throws { - try session([.connected]).sendProviderMessage( - encoder.encode(ProviderMessage.internetResourceEnabled(enabled))) + func getConfiguration() async throws -> Configuration? { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(ProviderMessage.getConfiguration(configurationHash)) + ) { data in + guard let data = data + else { + // Configuration hasn't changed + continuation.resume(returning: self.configurationCache) + return + } + + // Compute new hash + self.configurationHash = Data(SHA256.hash(data: data)) + + do { + let decoded = try self.decoder.decode(Configuration.self, from: data) + self.configurationCache = decoded + continuation.resume(returning: decoded) + } catch { + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + func setAuthURL(_ authURL: URL) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setAuthURL(authURL)) + } + + func setApiURL(_ apiURL: URL) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setApiURL(apiURL)) + } + + func setLogFilter(_ logFilter: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setLogFilter(logFilter)) + } + + func setActorName(_ actorName: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setActorName(actorName)) + } + + func setAccountSlug(_ accountSlug: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setAccountSlug(accountSlug)) + } + + func setInternetResourceEnabled(_ enabled: Bool) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setInternetResourceEnabled(enabled)) } func fetchResources() async throws -> ResourceList { @@ -102,11 +147,11 @@ class IPCClient { // Save hash to compare against self.resourceListHash = Data(SHA256.hash(data: data)) - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase do { - let decoded = try decoder.decode([Resource].self, from: data) + let decoded = try jsonDecoder.decode([Resource].self, from: data) self.resourcesListCache = ResourceList.loaded(decoded) continuation.resume(returning: self.resourcesListCache) @@ -121,15 +166,7 @@ class IPCClient { } func clearLogs() async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - try session().sendProviderMessage(encoder.encode(ProviderMessage.clearLogs)) { _ in - continuation.resume() - } - } catch { - continuation.resume(throwing: error) - } - } + try await sendMessageWithoutResponse(ProviderMessage.clearLogs) } func getLogFolderSize() async throws -> Int64 { @@ -164,8 +201,6 @@ class IPCClient { appender: @escaping (LogChunk) -> Void, errorHandler: @escaping (Error) -> Void ) { - let decoder = PropertyListDecoder() - func loop() { do { try session().sendProviderMessage( @@ -178,7 +213,7 @@ class IPCClient { return } - guard let chunk = try? decoder.decode( + guard let chunk = try? self.decoder.decode( LogChunk.self, from: data ) else { @@ -260,4 +295,17 @@ class IPCClient { throw Error.invalidStatus(session.status) } + + private func sendMessageWithoutResponse(_ message: ProviderMessage) async throws { + try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage(encoder.encode(message)) { _ in + continuation.resume() + } + } catch { + Log.error(error) + continuation.resume(throwing: error) + } + } + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift index 22be878e4..6b030fb1a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift @@ -41,12 +41,13 @@ public enum Telemetry { } } - public static func setEnvironmentOrClose(_ apiURLString: String) { + public static func setEnvironmentOrClose(_ apiURL: URL) { var environment: String? + let str = apiURL.absoluteString - if apiURLString.starts(with: "wss://api.firezone.dev") { + if str.starts(with: "wss://api.firezone.dev") { environment = "production" - } else if apiURLString.starts(with: "wss://api.firez.one") { + } else if str.starts(with: "wss://api.firez.one") { environment = "staging" } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift index 70c50f32c..575e9ebba 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -12,28 +12,16 @@ import NetworkExtension enum VPNConfigurationManagerError: Error { case managerNotInitialized - case savedProtocolConfigurationIsInvalid var localizedDescription: String { switch self { case .managerNotInitialized: return "NETunnelProviderManager is not yet initialized. Race condition?" - case .savedProtocolConfigurationIsInvalid: - return "Saved protocol configuration is invalid. Check types?" } } } public class VPNConfigurationManager { - public enum Keys { - static let actorName = "actorName" - static let authBaseURL = "authBaseURL" - static let apiURL = "apiURL" - public static let accountSlug = "accountSlug" - public static let logFilter = "logFilter" - public static let internetResourceEnabled = "internetResourceEnabled" - } - // Persists our tunnel settings let manager: NETunnelProviderManager @@ -44,11 +32,10 @@ public class VPNConfigurationManager { init() async throws { let protocolConfiguration = NETunnelProviderProtocol() let manager = NETunnelProviderManager() - let settings = Settings.defaultValue - protocolConfiguration.providerConfiguration = settings.toProviderConfiguration() + protocolConfiguration.providerConfiguration = nil protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier - protocolConfiguration.serverAddress = settings.apiURL + protocolConfiguration.serverAddress = "Firezone" // can be any non-empty string manager.localizedDescription = VPNConfigurationManager.bundleDescription manager.protocolConfiguration = protocolConfiguration @@ -74,76 +61,6 @@ public class VPNConfigurationManager { return nil } - func actorName() throws -> String? { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - return providerConfiguration[Keys.actorName] - } - - func internetResourceEnabled() throws -> Bool? { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - // TODO: Store Bool directly in VPN Configuration - if providerConfiguration[Keys.internetResourceEnabled] == "true" { - return true - } - - if providerConfiguration[Keys.internetResourceEnabled] == "false" { - return false - } - - return nil - } - - func save(authResponse: AuthResponse) async throws { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - providerConfiguration[Keys.actorName] = authResponse.actorName - providerConfiguration[Keys.accountSlug] = authResponse.accountSlug - - // Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure. - Telemetry.accountSlug = providerConfiguration[Keys.accountSlug] - - protocolConfiguration.providerConfiguration = providerConfiguration - manager.protocolConfiguration = protocolConfiguration - - try await enableConfiguration() - } - - func save(settings: Settings) async throws { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - var newProviderConfiguration = settings.toProviderConfiguration() - - // Don't clobber existing actorName - newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName] - - protocolConfiguration.providerConfiguration = newProviderConfiguration - protocolConfiguration.serverAddress = settings.apiURL - manager.protocolConfiguration = protocolConfiguration - - try await enableConfiguration() - - // Reconfigure our Telemetry environment in case it changed - Telemetry.setEnvironmentOrClose(settings.apiURL) - } - // If another VPN is activated on the system, ours becomes disabled. This is provided so that we may call it before // each start attempt in order to reactivate our configuration. func enableConfiguration() async throws { @@ -152,17 +69,67 @@ public class VPNConfigurationManager { try await manager.loadFromPreferences() } - func settings() throws -> Settings { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - return Settings.fromProviderConfiguration(providerConfiguration) - } - func session() -> NETunnelProviderSession? { return manager.connection as? NETunnelProviderSession } + + // Firezone 1.4.14 and below stored some app configuration in the VPN provider configuration fields. This has since + // been moved to a dedicated UserDefaults-backed persistent store. + func maybeMigrateConfiguration() async throws { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String], + let session = session() + else { return } + + let ipcClient = IPCClient(session: session) + + var migrated = false + + if let apiURLString = providerConfiguration["apiURL"], + let apiURL = URL(string: apiURLString), + apiURL.host != nil, + ["wss", "ws"].contains(apiURL.scheme) { + try await ipcClient.setApiURL(apiURL) + migrated = true + } + + if let authURLString = providerConfiguration["authBaseURL"], + let authURL = URL(string: authURLString), + authURL.host != nil, + ["https", "http"].contains(authURL.scheme) { + try await ipcClient.setAuthURL(authURL) + migrated = true + } + + if let actorName = providerConfiguration["actorName"] { + try await ipcClient.setActorName(actorName) + migrated = true + } + + if let accountSlug = providerConfiguration["accountSlug"] { + try await ipcClient.setAccountSlug(accountSlug) + migrated = true + } + + if let logFilter = providerConfiguration["logFilter"], + !logFilter.isEmpty { + try await ipcClient.setLogFilter(logFilter) + migrated = true + } + + if let internetResourceEnabled = providerConfiguration["internetResourceEnabled"], + ["false", "true"].contains(internetResourceEnabled) { + try await ipcClient.setInternetResourceEnabled(internetResourceEnabled == "true") + migrated = true + } + + if !migrated { return } + + // Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale. + protocolConfiguration.providerConfiguration = nil + protocolConfiguration.serverAddress = "Firezone" + manager.protocolConfiguration = protocolConfiguration + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift new file mode 100644 index 000000000..2be5eabb8 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift @@ -0,0 +1,47 @@ +// +// Configuration.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Foundation + +public struct Configuration: Codable { + public enum Keys { + public static let authURL = "dev.firezone.configuration.authURL" + public static let apiURL = "dev.firezone.configuration.apiURL" + public static let logFilter = "dev.firezone.configuration.logFilter" + public static let actorName = "dev.firezone.configuration.actorName" + public static let accountSlug = "dev.firezone.configuration.accountSlug" + public static let internetResourceEnabled = "dev.firezone.configuration.internetResourceEnabled" + public static let firezoneId = "dev.firezone.configuration.firezoneId" + } + + var actorName: String? + var authURL: URL? + var apiURL: URL? + var logFilter: String? + var accountSlug: String? + var firezoneId: String? + var internetResourceEnabled: Bool? + + public init(from dict: [String: Any?]) { + self.actorName = dict[Keys.actorName] as? String + self.authURL = dict[Keys.authURL] as? URL + self.apiURL = dict[Keys.apiURL] as? URL + self.logFilter = dict[Keys.logFilter] as? String + self.accountSlug = dict[Keys.accountSlug] as? String + self.firezoneId = dict[Keys.firezoneId] as? String + self.internetResourceEnabled = dict[Keys.internetResourceEnabled] as? Bool + } + +#if DEBUG + public static let defaultAuthURL = URL(string: "https://app.firez.one")! + public static let defaultApiURL = URL(string: "wss://api.firez.one")! + public static let defaultLogFilter = "debug" +#else + public static let defaultAuthURL = URL(string: "https://app.firezone.dev")! + public static let defaultApiURL = URL(string: "wss://api.firezone.dev")! + public static let defaultLogFilter = "info" +#endif +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift deleted file mode 100644 index ba00e6467..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// FirezoneId.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// -// Convenience wrapper for working with our firezone-id file stored by the -// tunnel process. - -import Foundation - -/// Prior to 1.4.0, our firezone-id was saved in a file accessible to both the -/// app and tunnel process. Starting with 1.4.0, -/// the macOS client uses a system extension, which makes sharing folders with -/// the app cumbersome, so we move to persisting the firezone-id only from the -/// tunnel process since that is the only place it's used. -/// -/// Can be refactored to remove the Version enum all clients >= 1.4.0 -public struct FirezoneId { - public enum Version { - case pre140 - case post140 - } - - public static func save(_ id: String) { - guard let fileURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId)? - .appendingPathComponent("firezone-id") - else { - // Nothing we can do about disk errors - return - } - - try? id.write( - to: fileURL, - atomically: true, - encoding: .utf8 - ) - } - - public static func load(_ version: Version) -> String? { - let appGroupId = switch version { - case .post140: - BundleHelper.appGroupId - case .pre140: -#if os(macOS) - "47R2M6779T.group.dev.firezone.firezone" -#elseif os(iOS) - "group.dev.firezone.firezone" -#endif - } - - guard let containerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupId), - let id = - try? String( - contentsOf: containerURL.appendingPathComponent("firezone-id")) - else { - return nil - } - - return id - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift index 4a6cd16cb..7bfee7c74 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift @@ -7,10 +7,19 @@ import Foundation +// TODO: Can we simplify this / abstract it? +// swiftlint:disable cyclomatic_complexity + public enum ProviderMessage: Codable { case getResourceList(Data) + case getConfiguration(Data) case signOut - case internetResourceEnabled(Bool) + case setAuthURL(URL) + case setApiURL(URL) + case setLogFilter(String) + case setActorName(String) + case setAccountSlug(String) + case setInternetResourceEnabled(Bool) case clearLogs case getLogFolderSize case exportLogs @@ -23,8 +32,14 @@ public enum ProviderMessage: Codable { enum MessageType: String, Codable { case getResourceList + case getConfiguration case signOut - case internetResourceEnabled + case setAuthURL + case setApiURL + case setLogFilter + case setActorName + case setAccountSlug + case setInternetResourceEnabled case clearLogs case getLogFolderSize case exportLogs @@ -35,12 +50,30 @@ public enum ProviderMessage: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(MessageType.self, forKey: .type) switch type { - case .internetResourceEnabled: + case .setAuthURL: + let value = try container.decode(URL.self, forKey: .value) + self = .setAuthURL(value) + case .setApiURL: + let value = try container.decode(URL.self, forKey: .value) + self = .setApiURL(value) + case .setLogFilter: + let value = try container.decode(String.self, forKey: .value) + self = .setLogFilter(value) + case .setActorName: + let value = try container.decode(String.self, forKey: .value) + self = .setActorName(value) + case .setAccountSlug: + let value = try container.decode(String.self, forKey: .value) + self = .setAccountSlug(value) + case .setInternetResourceEnabled: let value = try container.decode(Bool.self, forKey: .value) - self = .internetResourceEnabled(value) + self = .setInternetResourceEnabled(value) case .getResourceList: let value = try container.decode(Data.self, forKey: .value) self = .getResourceList(value) + case .getConfiguration: + let value = try container.decode(Data.self, forKey: .value) + self = .getConfiguration(value) case .signOut: self = .signOut case .clearLogs: @@ -53,15 +86,34 @@ public enum ProviderMessage: Codable { self = .consumeStopReason } } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .internetResourceEnabled(let value): - try container.encode(MessageType.internetResourceEnabled, forKey: .type) + case .setAuthURL(let value): + try container.encode(MessageType.setAuthURL, forKey: .type) + try container.encode(value, forKey: .value) + case .setApiURL(let value): + try container.encode(MessageType.setApiURL, forKey: .type) + try container.encode(value, forKey: .value) + case .setLogFilter(let value): + try container.encode(MessageType.setLogFilter, forKey: .type) + try container.encode(value, forKey: .value) + case .setActorName(let value): + try container.encode(MessageType.setActorName, forKey: .type) + try container.encode(value, forKey: .value) + case .setAccountSlug(let value): + try container.encode(MessageType.setAccountSlug, forKey: .type) + try container.encode(value, forKey: .value) + case .setInternetResourceEnabled(let value): + try container.encode(MessageType.setInternetResourceEnabled, forKey: .type) try container.encode(value, forKey: .value) case .getResourceList(let value): try container.encode(MessageType.getResourceList, forKey: .type) try container.encode(value, forKey: .value) + case .getConfiguration(let value): + try container.encode(MessageType.getConfiguration, forKey: .type) + try container.encode(value, forKey: .value) case .signOut: try container.encode(MessageType.signOut, forKey: .type) case .clearLogs: @@ -75,3 +127,5 @@ public enum ProviderMessage: Codable { } } } + +// swiftlint:enable cyclomatic_complexity diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift deleted file mode 100644 index ce9e61b42..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Settings.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Foundation - -struct Settings: Equatable { - var authBaseURL: String - var apiURL: String - var logFilter: String - var internetResourceEnabled: Bool? - - var isValid: Bool { - let authBaseURL = URL(string: authBaseURL) - let apiURL = URL(string: apiURL) - // Technically strings like "foo" are valid URLs, but their host component - // would be nil which crashes the ASWebAuthenticationSession view when - // signing in. We should also validate the scheme, otherwise ftp:// - // could be used for example which tries to open the Finder when signing - // in. 🙃 - return authBaseURL?.host != nil - && apiURL?.host != nil - && ["http", "https"].contains(authBaseURL?.scheme) - && ["ws", "wss"].contains(apiURL?.scheme) - && !logFilter.isEmpty - } - - // Convert provider configuration (which may have empty fields if it was tampered with) to Settings - static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { - if let providerConfiguration = providerConfiguration as? [String: String] { - return Settings( - authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL] - ?? Settings.defaultValue.authBaseURL, - apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL] - ?? Settings.defaultValue.apiURL, - logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter] - ?? Settings.defaultValue.logFilter, - internetResourceEnabled: getInternetResourceEnabled( - internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled]) - ) - } else { - return Settings.defaultValue - } - } - - static private func getInternetResourceEnabled(internetResourceEnabled: String?) -> Bool? { - guard let internetResourceEnabled = internetResourceEnabled, - let jsonData = internetResourceEnabled.data(using: .utf8) - else { return nil } - - return try? JSONDecoder().decode(Bool?.self, from: jsonData) - } - - // Used for initializing a new providerConfiguration from Settings - func toProviderConfiguration() -> [String: String] { - guard let data = try? JSONEncoder().encode(internetResourceEnabled), - let string = String(data: data, encoding: .utf8) - else { - fatalError("internetResourceEnabled should be encodable") - } - - return [ - VPNConfigurationManager.Keys.authBaseURL: authBaseURL, - VPNConfigurationManager.Keys.apiURL: apiURL, - VPNConfigurationManager.Keys.logFilter: logFilter, - VPNConfigurationManager.Keys.internetResourceEnabled: string - ] - } - - static let defaultValue: Settings = { - // Note: To see what the connlibLogFilterString values mean, see: - // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html - #if DEBUG - Settings( - authBaseURL: "https://app.firez.one", - apiURL: "wss://api.firez.one", - logFilter: "debug", - internetResourceEnabled: nil - ) - #else - Settings( - authBaseURL: "https://app.firezone.dev", - apiURL: "wss://api.firezone.dev", - logFilter: "info", - internetResourceEnabled: nil - ) - #endif - }() -} - -extension Settings: CustomStringConvertible { - var description: String { - "(\(authBaseURL), \(apiURL), \(logFilter)" - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift index 20cfb5648..93dd709ce 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift @@ -16,8 +16,9 @@ struct WebAuthSession { static let anchor = PresentationAnchor() static func signIn(store: Store) async throws { - guard let authURL = store.authURL(), - let authClient = try? AuthClient(authURL: authURL), + let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL + + guard let authClient = try? AuthClient(authURL: authURL), let url = try? authClient.build() else { // Should never get here because we perform URL validation on input, but handle this just in case diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 7cdeb056c..695daec7c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -17,24 +17,23 @@ import AppKit public final class Store: ObservableObject { @Published private(set) var favorites = Favorites() @Published private(set) var resourceList: ResourceList = .loading - @Published private(set) var actorName: String? - // Make our tunnel configuration convenient for SettingsView to consume - @Published private(set) var settings = Settings.defaultValue + // UserDefaults-backed app configuration that will publish updates to SwiftUI components + @Published private(set) var configuration: Configuration? - // Enacapsulate Tunnel status here to make it easier for other components - // to observe + // Enacapsulate Tunnel status here to make it easier for other components to observe @Published private(set) var status: NEVPNStatus? + // User notifications @Published private(set) var decision: UNAuthorizationStatus? - @Published private(set) var internetResourceEnabled: Bool? - #if os(macOS) // Track whether our system extension has been installed (macOS) @Published private(set) var systemExtensionStatus: SystemExtensionStatus? #endif + var firezoneId: String? + let sessionNotification = SessionNotification() private var resourcesTimer: Timer? @@ -78,11 +77,13 @@ public final class Store: ObservableObject { do { // Try to load existing configuration if let manager = try await VPNConfigurationManager.load() { + try await manager.maybeMigrateConfiguration() self.vpnConfigurationManager = manager - self.settings = try manager.settings() try await setupTunnelObservers() try await manager.enableConfiguration() try ipcClient().start() + self.configuration = try await ipcClient().getConfiguration() + Telemetry.firezoneId = configuration?.firezoneId } else { status = .invalid } @@ -105,13 +106,6 @@ public final class Store: ObservableObject { status = newStatus if status == .connected { - // Load saved actorName - actorName = try? manager().actorName() - - // Load saved internet resource status - internetResourceEnabled = try? manager().internetResourceEnabled() - - // Load Resources beginUpdatingResources() } else { endUpdatingResources() @@ -179,6 +173,9 @@ public final class Store: ObservableObject { // Create a new VPN configuration in system settings. self.vpnConfigurationManager = try await VPNConfigurationManager() + self.configuration = try await ipcClient().getConfiguration() + Telemetry.firezoneId = configuration?.firezoneId + try await setupTunnelObservers() } @@ -204,45 +201,74 @@ public final class Store: ObservableObject { self.decision = try await sessionNotification.askUserForNotificationPermissions() } - func authURL() -> URL? { - return URL(string: settings.authBaseURL) - } - func stop() throws { try ipcClient().stop() } func signIn(authResponse: AuthResponse) async throws { // Save actorName - self.actorName = authResponse.actorName + try await setActorName(authResponse.actorName) + try await setAccountSlug(authResponse.accountSlug) - try await manager().save(authResponse: authResponse) + try await manager().enableConfiguration() // Bring the tunnel up and send it a token to start try ipcClient().start(token: authResponse.token) } - func signOut() throws { - try ipcClient().signOut() + func signOut() async throws { + try await ipcClient().signOut() } func clearLogs() async throws { try await ipcClient().clearLogs() } - func saveSettings(_ newSettings: Settings) async throws { - try await manager().save(settings: newSettings) - self.settings = newSettings - } - func toggleInternetResource() async throws { - internetResourceEnabled = !(internetResourceEnabled ?? false) - settings.internetResourceEnabled = internetResourceEnabled - - try ipcClient().toggleInternetResource(enabled: internetResourceEnabled == true) - try await manager().save(settings: settings) + let enabled = configuration?.internetResourceEnabled == true + try await setInternetResourceEnabled(!enabled) } + // MARK: App configuration setters + + func setActorName(_ actorName: String) async throws { + try await ipcClient().setActorName(actorName) + self.configuration?.actorName = actorName + } + + func setAccountSlug(_ accountSlug: String) async throws { + try await ipcClient().setAccountSlug(accountSlug) + + // Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure. + Telemetry.accountSlug = accountSlug + } + + func setAuthURL(_ authURL: URL) async throws { + try await ipcClient().setAuthURL(authURL) + self.configuration?.authURL = authURL + } + + func setApiURL(_ apiURL: URL) async throws { + try await ipcClient().setApiURL(apiURL) + + // Reconfigure our Telemetry environment in case it changed + Telemetry.setEnvironmentOrClose(apiURL) + + self.configuration?.apiURL = apiURL + } + + func setLogFilter(_ logFilter: String) async throws { + try await ipcClient().setLogFilter(logFilter) + self.configuration?.logFilter = logFilter + } + + func setInternetResourceEnabled(_ enabled: Bool) async throws { + try await ipcClient().setInternetResourceEnabled(enabled) + self.configuration?.internetResourceEnabled = enabled + } + + // MARK: Private functions + private func start(token: String? = nil) throws { try ipcClient().start(token: token) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 8ee6a120a..ce61a3b11 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -37,8 +37,8 @@ public struct AppView: View { WindowDefinition.main.openWindow() } - // Close window upon launch for day-to-day use - if status != .invalid && systemExtensionStatus == .installed && FirezoneId.load(.pre140) != nil { + // Close window for day to day use + if status != .invalid && systemExtensionStatus == .installed && launchedBefore() { WindowDefinition.main.window()?.close() } }) @@ -75,6 +75,13 @@ public struct AppView: View { } } } + + private static func launchedBefore() -> Bool { + let bool = UserDefaults.standard.bool(forKey: "launchedBefore") + UserDefaults.standard.set(true, forKey: "launchedBefore") + + return bool + } #endif public init() {} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 170417a8f..3039553d6 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites - wasInternetResourceEnabled = store.internetResourceEnabled + wasInternetResourceEnabled = store.configuration?.internetResourceEnabled } func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers - wasInternetResourceEnabled = store.internetResourceEnabled + wasInternetResourceEnabled = store.configuration?.internetResourceEnabled } func updateStatusItemIcon() { @@ -350,7 +350,7 @@ public final class MenuBar: NSObject, ObservableObject { signOutMenuItem.isHidden = true settingsMenuItem.target = self case .connected, .reasserting, .connecting: - let title = "Signed in as \(store.actorName ?? "Unknown User")" + let title = "Signed in as \(store.configuration?.actorName ?? "Unknown User")" signInMenuItem.title = title signInMenuItem.target = nil signOutMenuItem.isHidden = false @@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject { return false } - return wasInternetResourceEnabled != store.internetResourceEnabled + return wasInternetResourceEnabled != store.configuration?.internetResourceEnabled } func refreshUpdateItem() { @@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceTitle(resource: Resource) -> String { - let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } @@ -526,7 +526,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceToggleTitle() -> String { - store.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource" + store.configuration?.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource" } // TODO: Refactor this when refactoring for macOS 13 @@ -689,7 +689,7 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func signOutButtonTapped() { - do { try store.signOut() } catch { Log.error(error) } + Task { do { try await store.signOut() } catch { Log.error(error) } } } @objc func grantPermissionMenuItemTapped() { @@ -723,10 +723,9 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func adminPortalButtonTapped() { - guard let url = URL(string: store.settings.authBaseURL) - else { return } + let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL - Task { await NSWorkspace.shared.openAsync(url) } + Task { await NSWorkspace.shared.openAsync(authURL) } } @objc func updateAvailableButtonTapped() { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index 6e41b3e50..443878aae 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -235,7 +235,7 @@ struct ToggleInternetResourceButton: View { @EnvironmentObject var store: Store private func toggleResourceEnabledText() -> String { - if store.internetResourceEnabled == true { + if store.configuration?.internetResourceEnabled == true { "Disable this resource" } else { "Enable this resource" diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 5704404a2..c91335c93 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -81,7 +81,7 @@ struct ResourceSection: View { @EnvironmentObject var store: Store private func internetResourceTitle(resource: Resource) -> String { - let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 9ea65c8d2..775ffff34 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -64,12 +64,101 @@ extension FileManager { } } -// TODO: Refactor body length +@MainActor +class SettingsViewModel: ObservableObject { + private let store: Store + private var cancellables = Set() + + @Published var authURLString: String + @Published var apiURLString: String + @Published var logFilterString: String + @Published var areSettingsDefault = true + @Published var areSettingsValid = true + @Published var areSettingsSaved = true + + init(store: Store) { + self.store = store + + self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString + self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString + self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter + + updateDerivedState() + + Publishers.CombineLatest3($authURLString, $apiURLString, $logFilterString) + .receive(on: RunLoop.main) + .sink { [weak self] (_, _, _) in + guard let self = self else { return } + + self.updateDerivedState() + } + .store(in: &cancellables) + } + + private func updateDerivedState() { + self.areSettingsSaved = (self.authURLString == store.configuration?.authURL?.absoluteString && + self.apiURLString == store.configuration?.apiURL?.absoluteString && + self.logFilterString == store.configuration?.logFilter) + + if let apiURL = URL(string: apiURLString), + let authURL = URL(string: authURLString), + authURL.host != nil, + apiURL.host != nil, + ["https", "http"].contains(authURL.scheme), + ["wss", "ws"].contains(apiURL.scheme), + !logFilterString.isEmpty { + + self.areSettingsValid = true + + } else { + + self.areSettingsValid = false + + } + + self.areSettingsDefault = (self.authURLString == Configuration.defaultAuthURL.absoluteString && + self.apiURLString == Configuration.defaultApiURL.absoluteString && + self.logFilterString == Configuration.defaultLogFilter) + } + + func applySettingsToStore() throws { + guard let authURL = URL(string: authURLString), + let apiURL = URL(string: apiURLString) + else { + Log.warning("Unexpectedly invalid settings") + + return + } + + Task { + try await store.setApiURL(apiURL) + try await store.setLogFilter(logFilterString) + try await store.setAuthURL(authURL) + + updateDerivedState() + } + } + + func revertToDefaultSettings() { + self.authURLString = Configuration.defaultAuthURL.absoluteString + self.apiURLString = Configuration.defaultApiURL.absoluteString + self.logFilterString = Configuration.defaultLogFilter + } + + func reloadSettingsFromStore() { + self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString + self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString + self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter + } +} + +// TODO: Move business logic to ViewModel to remove dependency on Store and fix body length // swiftlint:disable:next type_body_length public struct SettingsView: View { - @EnvironmentObject var store: Store + @StateObject private var viewModel: SettingsViewModel @Environment(\.dismiss) var dismiss - @State var settings = Settings.defaultValue + + private let store: Store enum ConfirmationAlertContinueAction: Int { case none @@ -81,9 +170,9 @@ public struct SettingsView: View { case .none: break case .saveSettings: - view.saveSettings() + Task { do { try await view.saveSettings() } catch { Log.error(error) } } case .saveAllSettingsAndDismiss: - view.saveAllSettingsAndDismiss() + Task { do { try await view.saveAllSettingsAndDismiss() } catch { Log.error(error) } } } } } @@ -117,7 +206,10 @@ public struct SettingsView: View { ) } - public init() {} + public init(store: Store) { + self.store = store + _viewModel = StateObject(wrappedValue: SettingsViewModel(store: store)) + } public var body: some View { #if os(iOS) @@ -128,7 +220,7 @@ public struct SettingsView: View { Image(systemName: "slider.horizontal.3") Text("Advanced") } - .badge(settings.isValid ? nil : "!") + .badge(viewModel.areSettingsValid ? nil : "!") logsTab .tabItem { Image(systemName: "doc.text") @@ -147,7 +239,7 @@ public struct SettingsView: View { } } .disabled( - (settings == store.settings || !settings.isValid) + (viewModel.areSettingsSaved || !viewModel.areSettingsValid) ) } ToolbarItem(placement: .navigationBarLeading) { @@ -175,9 +267,6 @@ public struct SettingsView: View { Text("Changing settings will sign you out and disconnect you from resources") } ) - .onAppear { - settings = store.settings - } #elseif os(macOS) VStack { @@ -209,9 +298,6 @@ public struct SettingsView: View { Text("Changing settings will sign you out and disconnect you from resources") } ) - .onAppear { - settings = store.settings - } .onDisappear(perform: { self.reloadSettings() }) #else #error("Unsupported platform") @@ -227,28 +313,19 @@ public struct SettingsView: View { Form { TextField( "Auth Base URL:", - text: Binding( - get: { settings.authBaseURL }, - set: { settings.authBaseURL = $0 } - ), + text: $viewModel.authURLString, prompt: Text(PlaceholderText.authBaseURL) ) TextField( "API URL:", - text: Binding( - get: { settings.apiURL }, - set: { settings.apiURL = $0 } - ), + text: $viewModel.apiURLString, prompt: Text(PlaceholderText.apiURL) ) TextField( "Log Filter:", - text: Binding( - get: { settings.logFilter }, - set: { settings.logFilter = $0 } - ), + text: $viewModel.logFilterString, prompt: Text(PlaceholderText.logFilter) ) @@ -268,16 +345,15 @@ public struct SettingsView: View { } } ) - .disabled(settings == store.settings || !store.settings.isValid) + .disabled(viewModel.areSettingsSaved || !viewModel.areSettingsValid) Button( "Reset to Defaults", action: { - settings = Settings.defaultValue - store.favorites.reset() + viewModel.revertToDefaultSettings() } ) - .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + .disabled(viewModel.areSettingsDefault) } .padding(.top, 5) } @@ -303,10 +379,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.authBaseURL, - text: Binding( - get: { settings.authBaseURL }, - set: { settings.authBaseURL = $0 } - ) + text: $viewModel.authURLString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -318,10 +391,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.apiURL, - text: Binding( - get: { settings.apiURL }, - set: { settings.apiURL = $0 } - ) + text: $viewModel.apiURLString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -333,10 +403,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.logFilter, - text: Binding( - get: { settings.logFilter }, - set: { settings.logFilter = $0 } - ) + text: $viewModel.logFilterString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -347,11 +414,10 @@ public struct SettingsView: View { Button( "Reset to Defaults", action: { - settings = Settings.defaultValue - store.favorites.reset() + viewModel.revertToDefaultSettings() } ) - .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + .disabled(viewModel.areSettingsDefault) Spacer() } }, @@ -477,13 +543,13 @@ public struct SettingsView: View { #endif } - func saveAllSettingsAndDismiss() { - saveSettings() + func saveAllSettingsAndDismiss() async throws { + try await saveSettings() dismiss() } func reloadSettings() { - settings = store.settings + viewModel.reloadSettingsFromStore() dismiss() } @@ -577,18 +643,16 @@ public struct SettingsView: View { } } - func saveSettings() { - Task { - do { - if [.connected, .connecting, .reasserting].contains(store.status) { - try self.store.signOut() - } - - try await store.saveSettings(settings) - } catch { - Log.error(error) + func saveSettings() async throws { + do { + if [.connected, .connecting, .reasserting].contains(store.status) { + try await self.store.signOut() } + } catch { + Log.error(error) } + + try viewModel.applySettingsToStore() } // Calculates the total size of our logs by summing the size of the diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index cd380ec6b..d20353c6a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -40,7 +40,7 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n ) } .sheet(isPresented: $isSettingsPresented) { - SettingsView() + SettingsView(store: store) } .navigationViewStyle(StackNavigationViewStyle()) } @@ -60,7 +60,7 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n private var authMenu: some View { Menu { if store.status == .connected { - Text("Signed in as \(store.actorName ?? "Unknown user")") + Text("Signed in as \(store.configuration?.actorName ?? "Unknown user")") Button( action: { signOutButtonTapped() @@ -120,17 +120,19 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n } func signOutButtonTapped() { - do { - try store.signOut() - } catch { - Log.error(error) + Task { + do { + try await store.signOut() + } catch { + Log.error(error) - self.errorHandler.handle( - ErrorAlert( - title: "Error signing out", - error: error + self.errorHandler.handle( + ErrorAlert( + title: "Error signing out", + error: error + ) ) - ) + } } } } diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 1a0e8715f..db8afd80c 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -164,7 +164,7 @@ class Adapter { } /// Currently disabled resources - private var internetResourceEnabled: Bool = false + private var internetResourceEnabled: Bool /// Cache of internet resource private var internetResource: Resource? @@ -173,14 +173,14 @@ class Adapter { private var resourceListJSON: String? /// Starting parameters - private let apiURL: String + private let apiURL: URL private let token: Token private let id: String private let logFilter: String private let connlibLogFolderPath: String init( - apiURL: String, + apiURL: URL, token: Token, id: String, logFilter: String, @@ -207,7 +207,7 @@ class Adapter { } /// Start the tunnel. - public func start() throws { + func start() throws { Log.log("Adapter.start") guard session == nil else { @@ -223,7 +223,7 @@ class Adapter { // Grab a session pointer session = try WrappedSession.connect( - apiURL, + "\(apiURL)", "\(token)", "\(id)", "\(Telemetry.accountSlug!)", @@ -255,7 +255,7 @@ class Adapter { /// /// This can happen before the tunnel is in the tunnelReady state, such as if the portal /// is slow to send the init. - public func stop() { + func stop() { Log.log("Adapter.stop") // Assigning `nil` will invoke `Drop` on the Rust side @@ -267,7 +267,7 @@ class Adapter { /// Get the current set of resources in the completionHandler, only returning /// them if the resource list has changed. - public func getResourcesIfVersionDifferentFrom( + func getResourcesIfVersionDifferentFrom( hash: Data, completionHandler: @escaping (String?) -> Void ) { // This is async to avoid blocking the main UI thread @@ -290,7 +290,7 @@ class Adapter { return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? [] } - public func setInternetResourceEnabled(_ enabled: Bool) { + func setInternetResourceEnabled(_ enabled: Bool) { workQueue.async { [weak self] in guard let self = self else { return } @@ -299,7 +299,7 @@ class Adapter { } } - public func resourcesUpdated() { + func resourcesUpdated() { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase @@ -356,7 +356,7 @@ extension Adapter { extension Adapter: CallbackHandlerDelegate { // swiftlint:disable:next function_parameter_count - public func onSetInterfaceConfig( + func onSetInterfaceConfig( tunnelAddressIPv4: String, tunnelAddressIPv6: String, searchDomain: String?, @@ -396,7 +396,7 @@ extension Adapter: CallbackHandlerDelegate { } } - public func onUpdateResources(resourceList: String) { + func onUpdateResources(resourceList: String) { // This is a queued callback to ensure ordering workQueue.async { [weak self] in guard let self = self else { return } @@ -410,7 +410,7 @@ extension Adapter: CallbackHandlerDelegate { } } - public func onDisconnect(error: String) { + func onDisconnect(error: String) { // Immediately invalidate our session pointer to prevent workQueue items from trying to use it. // Assigning to `nil` will invoke `Drop` on the Rust side. session = nil diff --git a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift new file mode 100644 index 000000000..6d6012f7b --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift @@ -0,0 +1,107 @@ +// +// ConfigurationManager.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// A wrapper around UserDefaults + +import Foundation +import FirezoneKit +import CryptoKit + +class ConfigurationManager { + static let shared = ConfigurationManager() + + let encoder = PropertyListEncoder() + + private var userDefaults: UserDefaults + + var authURL: URL? { + get { userDefaults.url(forKey: Configuration.Keys.authURL) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.authURL) } + } + + var apiURL: URL? { + get { userDefaults.url(forKey: Configuration.Keys.apiURL) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.apiURL) } + } + + var logFilter: String? { + get { userDefaults.string(forKey: Configuration.Keys.logFilter) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.logFilter) } + } + + var actorName: String? { + get { userDefaults.string(forKey: Configuration.Keys.actorName) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.actorName) } + } + + var accountSlug: String? { + get { userDefaults.string(forKey: Configuration.Keys.accountSlug) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.accountSlug) } + } + + var internetResourceEnabled: Bool? { + get { userDefaults.bool(forKey: Configuration.Keys.internetResourceEnabled) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.internetResourceEnabled) } + } + + var firezoneId: String? { + get { userDefaults.string(forKey: Configuration.Keys.firezoneId) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.firezoneId) } + } + + private init() { + self.userDefaults = UserDefaults.standard + + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId), + let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent("firezone-id")) { + + self.firezoneId = idFromFile + try? FileManager.default.removeItem(at: containerURL.appendingPathComponent("firezone-id")) + Telemetry.firezoneId = idFromFile + + return + } + + if let firezoneId { + Telemetry.firezoneId = firezoneId + return + } + + self.firezoneId = UUID().uuidString + Telemetry.firezoneId = firezoneId + } + + func toDataIfChanged(hash: Data?) -> Data? { + var dict: [String: Any] = [:] + + dict[Configuration.Keys.accountSlug] = accountSlug + dict[Configuration.Keys.actorName] = actorName + dict[Configuration.Keys.firezoneId] = firezoneId + dict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled + dict[Configuration.Keys.authURL] = authURL + dict[Configuration.Keys.apiURL] = apiURL + dict[Configuration.Keys.logFilter] = logFilter + + let configuration = Configuration(from: dict) + + do { + let encoded = try encoder.encode(configuration) + let hashData = Data(SHA256.hash(data: encoded)) + + if hash == hashData { + // same + return nil + } + + return encoded + + } catch { + Log.error(error) + } + + return nil + } +} diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 4257cf574..f222a9f46 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -10,7 +10,10 @@ import System import os enum PacketTunnelProviderError: Error { - case savedProtocolConfigurationIsInvalid(String) + case apiURLIsInvalid + case logFilterIsInvalid + case accountSlugIsInvalid + case firezoneIdIsInvalid case tokenNotFoundInKeychain } @@ -31,8 +34,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { super.init() } - // TODO: Refactor this to shorten function body - // swiftlint:disable:next function_body_length override func startTunnel( options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void @@ -50,49 +51,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Try to save the token back to the Keychain but continue if we can't do { try token.save() } catch { Log.error(error) } - // Use and persist the provided ID or try loading it from disk, - // generating a new one if both of those are nil. - let id = loadAndSaveFirezoneId(from: options) + // The firezone id should be initialized by now + guard let id = ConfigurationManager.shared.firezoneId + else { + throw PacketTunnelProviderError.firezoneIdIsInvalid + } // Now we should have a token, so continue connecting - guard let apiURL = protocolConfiguration.serverAddress - else { - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("serverAddress") - } + let apiURL = ConfigurationManager.shared.apiURL ?? Configuration.defaultApiURL // Reconfigure our Telemetry environment now that we know the API URL Telemetry.setEnvironmentOrClose(apiURL) - guard - let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? - .providerConfiguration as? [String: String], - let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter] - else { - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter") - } + let logFilter = ConfigurationManager.shared.logFilter ?? Configuration.defaultLogFilter // Hydrate telemetry account slug - guard let accountSlug = providerConfiguration[VPNConfigurationManager.Keys.accountSlug] - else { - // This can happen if the user deletes the VPN configuration while it's - // connected. The system will try to restart us with a fresh config - // once the user fixes the problem, but we'd rather not connect - // without a slug. - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("providerConfiguration.accountSlug") - } - + let accountSlug = ConfigurationManager.shared.accountSlug Telemetry.accountSlug = accountSlug - let internetResourceEnabled: Bool = - if let internetResourceEnabledJSON = providerConfiguration[ - VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) { - (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false - } else { - false - } + let internetResourceEnabled = ConfigurationManager.shared.internetResourceEnabled ?? false let adapter = Adapter( apiURL: apiURL, @@ -111,11 +88,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // `connected`. completionHandler(nil) - } catch let error as PacketTunnelProviderError { - - // These are expected, no need to log them - completionHandler(error) - } catch { Log.error(error) @@ -166,31 +138,71 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // It would be helpful to be able to encapsulate Errors here. To do that // we need to update ProviderMessage to encode/decode Result to and from Data. + // TODO: Move to a more abstract IPC protocol + // swiftlint:disable:next cyclomatic_complexity function_body_length override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { - guard let providerMessage = try? PropertyListDecoder().decode(ProviderMessage.self, from: message) else { return } + do { + let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message) - switch providerMessage { - case .internetResourceEnabled(let value): - adapter?.setInternetResourceEnabled(value) - case .signOut: - do { - try Token.delete() - } catch { - Log.error(error) - } - case .getResourceList(let value): - adapter?.getResourcesIfVersionDifferentFrom(hash: value) { resourceListJSON in - completionHandler?(resourceListJSON?.data(using: .utf8)) - } - case .clearLogs: - clearLogs(completionHandler) - case .getLogFolderSize: - getLogFolderSize(completionHandler) - case .exportLogs: - exportLogs(completionHandler!) + switch providerMessage { + case .getConfiguration(let hash): + let configurationPayload = ConfigurationManager.shared.toDataIfChanged(hash: hash) + completionHandler?(configurationPayload) + case .setAuthURL(let authURL): + ConfigurationManager.shared.authURL = authURL + completionHandler?(nil) + case .setApiURL(let apiURL): + ConfigurationManager.shared.apiURL = apiURL + completionHandler?(nil) + case .setActorName(let actorName): + ConfigurationManager.shared.actorName = actorName + completionHandler?(nil) + case .setAccountSlug(let accountSlug): + ConfigurationManager.shared.accountSlug = accountSlug + completionHandler?(nil) + case .setLogFilter(let logFilter): + ConfigurationManager.shared.logFilter = logFilter + completionHandler?(nil) + case .setInternetResourceEnabled(let enabled): + ConfigurationManager.shared.internetResourceEnabled = enabled + adapter?.setInternetResourceEnabled(enabled) + completionHandler?(nil) + case .signOut: + do { + try Token.delete() + Task { + await stopTunnel(with: .userInitiated) + completionHandler?(nil) + } + } catch { + Log.error(error) + completionHandler?(nil) + } + case .getResourceList(let hash): + guard let adapter = adapter + else { + Log.warning("Adapter is nil") + completionHandler?(nil) - case .consumeStopReason: - consumeStopReason(completionHandler!) + return + } + + adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in + completionHandler?(resourceListJSON?.data(using: .utf8)) + } + case .clearLogs: + clearLogs(completionHandler) + case .getLogFolderSize: + getLogFolderSize(completionHandler) + case .exportLogs: + exportLogs(completionHandler!) + + case .consumeStopReason: + consumeStopReason(completionHandler!) + } + } catch { + Log.error(error) + completionHandler?(nil) } } @@ -208,20 +220,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return Token(passedToken) ?? keychainToken } - func loadAndSaveFirezoneId(from options: [String: NSObject]?) -> String { - let passedId = options?["id"] as? String - let persistedId = FirezoneId.load(.post140) - - let id = passedId ?? persistedId ?? UUID().uuidString - - FirezoneId.save(id) - - // Hydrate the telemetry userId with our firezone id - Telemetry.firezoneId = id - - return id - } - func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) { do { try Log.clear(in: SharedAccess.logFolderURL)