diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 65dfa8054..197d6473e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3401,12 +3401,9 @@ dependencies = [ [[package]] name = "line-wrap" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" -dependencies = [ - "safemem", -] +checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e" [[package]] name = "linked-hash-map" @@ -4510,9 +4507,9 @@ checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" [[package]] name = "plist" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef" +checksum = "d9d34169e64b3c7a80c8621a48adaf44e0cf62c78a9b25dd9dd35f1881a17cf9" dependencies = [ "base64 0.21.7", "indexmap 2.2.6", @@ -5241,12 +5238,6 @@ dependencies = [ "libc", ] -[[package]] -name = "safemem" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" - [[package]] name = "same-file" version = "1.0.6" diff --git a/rust/connlib/clients/apple/src/lib.rs b/rust/connlib/clients/apple/src/lib.rs index 667271983..503fac5ec 100644 --- a/rust/connlib/clients/apple/src/lib.rs +++ b/rust/connlib/clients/apple/src/lib.rs @@ -44,6 +44,9 @@ mod ffi { callback_handler: CallbackHandler, ) -> Result; + fn reconnect(&mut self); + #[swift_bridge(swift_name = "setDns")] + fn set_dns(&mut self, dns_servers: String); fn disconnect(self); } @@ -58,9 +61,6 @@ mod ffi { dnsAddresses: String, ); - #[swift_bridge(swift_name = "onTunnelReady")] - fn on_tunnel_ready(&self); - #[swift_bridge(swift_name = "onUpdateRoutes")] fn on_update_routes(&self, routeList4: String, routeList6: String); @@ -69,10 +69,6 @@ mod ffi { #[swift_bridge(swift_name = "onDisconnect")] fn on_disconnect(&self, error: String); - - // TODO: remove in favor of set_dns - #[swift_bridge(swift_name = "getSystemDefaultResolvers")] - fn get_system_default_resolvers(&self) -> String; } } @@ -115,10 +111,6 @@ impl Callbacks for CallbackHandler { None } - fn on_tunnel_ready(&self) { - self.inner.on_tunnel_ready(); - } - fn on_update_routes( &self, route_list_4: Vec, @@ -182,10 +174,6 @@ impl WrappedSession { let handle = init_logging(log_dir.into(), log_filter).map_err(|e| e.to_string())?; let secret = SecretString::from(token); - let resolvers_json = callback_handler.get_system_default_resolvers(); - let resolvers: Vec = serde_json::from_str(&resolvers_json) - .expect("developer error: failed to deserialize resolvers"); - let (private_key, public_key) = keypair(); let login = LoginUrl::client( api_url.as_str(), @@ -217,14 +205,21 @@ impl WrappedSession { ) .map_err(|err| err.to_string())?; - session.set_dns(resolvers); - Ok(Self { inner: session, runtime, }) } + fn reconnect(&mut self) { + self.inner.reconnect() + } + + fn set_dns(&mut self, dns_servers: String) { + self.inner + .set_dns(serde_json::from_str(&dns_servers).unwrap()) + } + fn disconnect(self) { self.inner.disconnect() } diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index 1767cff9b..1ce5f560b 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -14,8 +14,6 @@ 05CF1D16290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; }; 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; }; 05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; }; - 6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA39A032A6A7248000F0157 /* NetworkResource.swift */; }; - 6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA39A032A6A7248000F0157 /* NetworkResource.swift */; }; 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; }; 6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; }; 6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; }; @@ -30,8 +28,10 @@ 794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; }; 794C38172970A26A0029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38162970A26A0029F38F /* FirezoneKit */; }; 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; }; - 8D28EB992B35FBD70083621C /* Resolv.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D28EB982B35FBD70083621C /* Resolv.swift */; }; - 8D28EB9A2B35FBD70083621C /* Resolv.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D28EB982B35FBD70083621C /* Resolv.swift */; }; + 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; }; + 8D69392F2BA2502000AF4396 /* DeviceMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */; }; + 8D6939302BA2502000AF4396 /* DeviceMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */; }; + 8D6939322BA2521A00AF4396 /* SystemConfigurationResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */; }; 8DC08BCB2B296C4500675F46 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BCA2B296C4500675F46 /* libresolv.9.tbd */; }; 8DC08BCD2B296C5900675F46 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */; }; 8DC08BD22B297B7B00675F46 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BD12B297B7B00675F46 /* libresolv.tbd */; }; @@ -108,7 +108,6 @@ 05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FirezoneNetworkExtension_iOS.entitlements; sourceTree = ""; }; 05CF1D03290B1DCD00CF4755 /* FirezoneNetworkExtensionmacOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirezoneNetworkExtensionmacOS.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; - 6FA39A032A6A7248000F0157 /* NetworkResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResource.swift; sourceTree = ""; }; 6FE454EA2A5BFABA006549B1 /* Adapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = ""; }; 6FE455082A5D110D006549B1 /* CallbackHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallbackHandler.swift; sourceTree = ""; }; 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = ""; }; @@ -116,7 +115,9 @@ 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = ""; }; 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = ""; }; 6FFECD5B2AD6998400E00273 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; - 8D28EB982B35FBD70083621C /* Resolv.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resolv.swift; sourceTree = ""; }; + 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = ""; }; + 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMetadata.swift; sourceTree = ""; }; + 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = ""; }; 8DC08BCA2B296C4500675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.2.sdk/usr/lib/libresolv.9.tbd; sourceTree = DEVELOPER_DIR; }; 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; }; @@ -177,17 +178,18 @@ 05833DF928F73B070008FAB0 /* FirezoneNetworkExtension */ = { isa = PBXGroup; children = ( + 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */, + 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */, + 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */, 05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension_iOS.entitlements */, 05CF1CDE290B1A9000CF4755 /* FirezoneNetworkExtension_macOS.entitlements */, 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, - 6FA39A032A6A7248000F0157 /* NetworkResource.swift */, 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, 6FE4550E2A5D112C006549B1 /* connlib-client-apple.swift */, 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */, - 8D28EB982B35FBD70083621C /* Resolv.swift */, ); path = FirezoneNetworkExtension; sourceTree = ""; @@ -498,12 +500,12 @@ files = ( 8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */, 6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */, - 8D28EB992B35FBD70083621C /* Resolv.swift in Sources */, 6FE4550F2A5D112C006549B1 /* connlib-client-apple.swift in Sources */, 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */, 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */, - 6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */, 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, + 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */, + 8D69392F2BA2502000AF4396 /* DeviceMetadata.swift in Sources */, 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -514,13 +516,13 @@ files = ( 8DC08BD62B297DA400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */, 6FE4550A2A5D110D006549B1 /* CallbackHandler.swift in Sources */, - 8D28EB9A2B35FBD70083621C /* Resolv.swift in Sources */, 6FE455102A5D112C006549B1 /* connlib-client-apple.swift in Sources */, 05CF1D16290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */, + 8D6939302BA2502000AF4396 /* DeviceMetadata.swift in Sources */, 6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */, - 6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */, 6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, 6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */, + 8D6939322BA2521A00AF4396 /* SystemConfigurationResolvers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 3c927d49e..0a4d0508f 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -29,7 +29,8 @@ struct FirezoneApp: App { StateObject( wrappedValue: AskPermissionViewModel( tunnelStore: appStore.tunnelStore, - sessionNotificationHelper: SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore) + sessionNotificationHelper: SessionNotificationHelper( + logger: appStore.logger, tunnelStore: appStore.tunnelStore) ) ) appDelegate.appStore = appStore @@ -47,7 +48,7 @@ struct FirezoneApp: App { } #else WindowGroup( - "Firezone (VPN Permission)", + "Welcome to Firezone", id: AppStore.WindowDefinition.askPermission.identifier ) { AskPermissionView(model: askPermissionViewModel) @@ -74,23 +75,16 @@ struct FirezoneApp: App { private var isAppLaunched = false private var menuBar: MenuBar? - public var appStore: AppStore? { - didSet { - if self.isAppLaunched { - // This is not expected to happen because appStore - // should be set before the app finishes launching. - // This code is only a contingency. - if let appStore = self.appStore { - self.menuBar = MenuBar(appStore: appStore) - } - } - } - } + public var appStore: AppStore? func applicationDidFinishLaunching(_: Notification) { self.isAppLaunched = true if let appStore = self.appStore { - self.menuBar = MenuBar(appStore: appStore) + self.menuBar = MenuBar( + tunnelStore: appStore.tunnelStore, + settingsViewModel: appStore.settingsViewModel, + logger: appStore.logger + ) } // SwiftUI will show the first window group, so close it on launch @@ -98,7 +92,7 @@ struct FirezoneApp: App { } func applicationWillTerminate(_: Notification) { - self.appStore?.authStore.cancelSignIn() + self.appStore?.tunnelStore.cancelSignIn() } } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift index 3e5489d60..b9744de1d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift @@ -33,14 +33,13 @@ public final class AskPermissionViewModel: ObservableObject { self.tunnelStore = tunnelStore self.sessionNotificationHelper = sessionNotificationHelper - tunnelStore.$tunnelAuthStatus - .filter { $0.isInitialized } - .sink { [weak self] tunnelAuthStatus in + tunnelStore.$status + .sink { [weak self] status in guard let self = self else { return } Task { await MainActor.run { - if case .noTunnelFound = tunnelAuthStatus { + if case .invalid = status { self.needsTunnelPermission = true } else { self.needsTunnelPermission = false @@ -66,19 +65,12 @@ public final class AskPermissionViewModel: ObservableObject { } } .store(in: &cancellables) - } func grantPermissionButtonTapped() { Task { do { - try await self.tunnelStore.createTunnel() - } catch { - #if os(macOS) - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { - AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront() - } - #endif + try await self.tunnelStore.createManager() } } } @@ -90,7 +82,7 @@ public final class AskPermissionViewModel: ObservableObject { #endif #if os(macOS) - func closeAskPermissionWindow() { + public func closeAskPermissionWindow() { AppStore.WindowDefinition.askPermission.window()?.close() } #endif @@ -111,11 +103,10 @@ public struct AskPermissionView: View { Image("LogoText") .resizable() .scaledToFit() - .frame(maxWidth: 600) + .frame(maxWidth: 320) .padding(.horizontal, 10) Spacer() if $model.needsTunnelPermission.wrappedValue { - #if os(macOS) Text( "Firezone requires your permission to create VPN tunnels.\nUntil it has that permission, all functionality will be disabled." @@ -180,7 +171,6 @@ public struct AskPermissionView: View { .multilineTextAlignment(.center) #endif } else { - #if os(macOS) Text( "You can sign in to Firezone by clicking on the Firezone icon in the macOS menu bar.\nYou may now close this window." diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift index 01cc816e4..ee310abf6 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift @@ -13,19 +13,19 @@ import XCTestDynamicOverlay @MainActor final class AuthViewModel: ObservableObject { - let authStore: AuthStore + let tunnelStore: TunnelStore var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined") private var cancellables = Set() - init(authStore: AuthStore) { - self.authStore = authStore + init(tunnelStore: TunnelStore) { + self.tunnelStore = tunnelStore } func signInButtonTapped() async { do { - try await authStore.signIn() + try await tunnelStore.signIn() } catch { dump(error) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift index 38d9720cb..712293251 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift @@ -15,70 +15,47 @@ import SwiftUI final class MainViewModel: ObservableObject { private let logger: AppLogger private var cancellables: Set = [] + let tunnelStore: TunnelStore - let appStore: AppStore @Dependency(\.mainQueue) private var mainQueue - @Published var loginStatus: AuthStore.LoginStatus = .uninitialized - @Published var tunnelStatus: NEVPNStatus = .invalid - @Published var orderedResources: [DisplayableResources.Resource] = [] + @Published private(set) var resources: [Resource]? - init(appStore: AppStore) { - self.appStore = appStore - self.logger = appStore.logger + init(tunnelStore: TunnelStore, logger: AppLogger) { + self.tunnelStore = tunnelStore + self.logger = logger setupObservers() } private func setupObservers() { - appStore.authStore.$loginStatus - .receive(on: mainQueue) - .sink { [weak self] loginStatus in - self?.loginStatus = loginStatus - } - .store(in: &cancellables) - - appStore.tunnelStore.$status + tunnelStore.$status .receive(on: mainQueue) .sink { [weak self] status in - self?.tunnelStatus = status + guard let self = self else { return } if status == .connected { - self?.appStore.tunnelStore.beginUpdatingResources() + self.tunnelStore.beginUpdatingResources() } else { - self?.appStore.tunnelStore.endUpdatingResources() + self.tunnelStore.endUpdatingResources() } } .store(in: &cancellables) - appStore.tunnelStore.$resources + tunnelStore.$resourceListJSON .receive(on: mainQueue) - .sink { [weak self] resources in - guard let self = self else { return } - self.orderedResources = resources.orderedResources.map { - DisplayableResources.Resource(name: $0.name, location: $0.location) - } + .sink { [weak self] json in + guard let self = self, + let json = json, + let data = json.data(using: .utf8) + else { return } + + resources = try? JSONDecoder().decode([Resource].self, from: data) } .store(in: &cancellables) } func signOutButtonTapped() { Task { - await appStore.authStore.signOut() - } - } - - func startTunnel() async { - if case .signedIn = self.loginStatus { - appStore.authStore.startTunnel() - } - } - - func stopTunnel() { - Task { - do { - try await appStore.tunnelStore.stop() - } catch { - logger.error("\(#function): Error stopping tunnel: \(error)") - } + try await tunnelStore.signOut() } } } @@ -90,56 +67,53 @@ import SwiftUI List { Section(header: Text("Authentication")) { Group { - switch self.model.loginStatus { - case .signedIn(let actorName): - if self.model.tunnelStatus == .connected { - HStack { - Text(actorName.isEmpty ? "Signed in" : "Signed in as") - Spacer() - Text(actorName) - .foregroundColor(.secondary) - } - HStack { - Spacer() - Button("Sign Out") { - self.model.signOutButtonTapped() - } - Spacer() - } - } else { - Text(self.model.tunnelStatus.description) + if case .connected = model.tunnelStore.status { + let actorName = model.tunnelStore.actorName() ?? "" + HStack { + Text(actorName.isEmpty ? "Signed in" : "Signed in as") + Spacer() + Text(actorName).foregroundColor(.secondary) } - case .signedOut: - Text("Signed Out") - case .uninitialized: - Text("Initializing…") - case .needsTunnelCreationPermission: - Text("Requires VPN permission") + HStack { + Spacer() + Button("Sign Out") { + model.signOutButtonTapped() + } + Spacer() + } + } else { + Text(model.tunnelStore.status.description) } } } - if case .signedIn = self.model.loginStatus, self.model.tunnelStatus == .connected { + if case .connected = model.tunnelStore.status { Section(header: Text("Resources")) { - if self.model.orderedResources.isEmpty { - Text("No resources") - } else { - ForEach(self.model.orderedResources) { resource in - Menu(content: { - Button { - self.copyResourceTapped(resource) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - }, label : { - HStack { - Text(resource.name) - .foregroundColor(.primary) - Spacer() - Text(resource.location) - .foregroundColor(.secondary) - } - }) + if let resources = model.resources { + if resources.isEmpty { + Text("No Resources") + } else { + ForEach(resources) { resource in + Menu( + content: { + Button { + copyResourceTapped(resource) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + }, + label: { + HStack { + Text(resource.name) + .foregroundColor(.primary) + Spacer() + Text(resource.address) + .foregroundColor(.secondary) + } + }) + } } + } else { + Text("Loading Resources...") } } } @@ -148,9 +122,9 @@ import SwiftUI .navigationTitle("Firezone") } - private func copyResourceTapped(_ resource: DisplayableResources.Resource) { + private func copyResourceTapped(_ resource: Resource) { let pasteboard = UIPasteboard.general - pasteboard.string = resource.location + pasteboard.string = resource.address } } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift index ab5cb2f97..41404607d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift @@ -16,63 +16,44 @@ enum SettingsViewError: Error { } public final class SettingsViewModel: ObservableObject { - let authStore: AuthStore + let tunnelStore: TunnelStore - var tunnelAuthStatus: TunnelAuthStatus { - authStore.tunnelStore.tunnelAuthStatus - } - - @Published var advancedSettings: AdvancedSettings + @Published var settings: Settings let logger: AppLogger - public var onSettingsSaved: () -> Void = unimplemented() private var cancellables = Set() - public init(authStore: AuthStore, logger: AppLogger) { - self.authStore = authStore + public init(tunnelStore: TunnelStore, logger: AppLogger) { + self.tunnelStore = tunnelStore self.logger = logger - advancedSettings = AdvancedSettings.defaultValue + settings = Settings.defaultValue + loadSettings() } func loadSettings() { + // Load settings from saved VPN Profile Task { - authStore.tunnelStore.$tunnelAuthStatus - .first { $0.isInitialized } + tunnelStore.$settings .receive(on: RunLoop.main) - .sink { [weak self] tunnelAuthStatus in + .sink { [weak self] settings in guard let self = self else { return } - self.advancedSettings = - authStore.tunnelStore.advancedSettings() ?? AdvancedSettings.defaultValue + + self.settings = settings } .store(in: &cancellables) } } - func saveAdvancedSettings() { - let isChanged = (authStore.tunnelStore.advancedSettings() != advancedSettings) - guard isChanged else { - advancedSettings.isSavedToDisk = true - return - } + func saveSettings() { Task { - if case .signedIn = self.tunnelAuthStatus { - await authStore.signOut() - } - let authBaseURLString = advancedSettings.authBaseURLString - guard URL(string: authBaseURLString) != nil else { - logger.error( - "Not saving advanced settings because authBaseURL '\(authBaseURLString)' is invalid" - ) - return + if [.connected, .connecting, .reasserting].contains(tunnelStore.status) { + _ = try await tunnelStore.signOut() } do { - try await authStore.tunnelStore.saveAdvancedSettings(advancedSettings) + try await tunnelStore.save(settings) } catch { - logger.error("Error saving advanced settings to tunnel store: \(error)") - } - await MainActor.run { - advancedSettings.isSavedToDisk = true + logger.error("Error saving settings to tunnel store: \(error)") } } } @@ -80,7 +61,6 @@ public final class SettingsViewModel: ObservableObject { func calculateLogDirSize(logger: AppLogger) -> String? { logger.log("\(#function)") - let startTime = DispatchTime.now() guard let logFilesFolderURL = SharedAccess.logFolderURL else { logger.error("\(#function): Log folder is unavailable") return nil @@ -107,10 +87,6 @@ public final class SettingsViewModel: ObservableObject { return nil } - let elapsedTime = - (DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000 - logger.log("\(#function): Finished calculating (\(totalSize) bytes) in \(elapsedTime) ms") - let byteCountFormatter = ByteCountFormatter() byteCountFormatter.countStyle = .file byteCountFormatter.allowsNonnumericFormatting = false @@ -121,7 +97,6 @@ public final class SettingsViewModel: ObservableObject { func clearAllLogs(logger: AppLogger) throws { logger.log("\(#function)") - let startTime = DispatchTime.now() guard let logFilesFolderURL = SharedAccess.logFolderURL else { logger.error("\(#function): Log folder is unavailable") return @@ -146,10 +121,6 @@ public final class SettingsViewModel: ObservableObject { } } - let elapsedTime = - (DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000 - logger.log("\(#function): Finished removing log files in \(elapsedTime) ms") - if unremovedFilesCount > 0 { logger.log("\(#function): Unable to remove \(unremovedFilesCount) files") } @@ -196,15 +167,15 @@ public struct SettingsView: View { enum ConfirmationAlertContinueAction: Int { case none - case saveAdvancedSettings + case saveSettings case saveAllSettingsAndDismiss func performAction(on view: SettingsView) { switch self { case .none: break - case .saveAdvancedSettings: - view.saveAdvancedSettings() + case .saveSettings: + view.saveSettings() case .saveAllSettingsAndDismiss: view.saveAllSettingsAndDismiss() } @@ -235,7 +206,7 @@ public struct SettingsView: View { static let forAdvanced = try! AttributedString( markdown: """ **WARNING:** These settings are intended for internal debug purposes **only**. \ - Changing these is not supported and will disrupt access to your Firezone resources. + Changing these will disrupt access to your Firezone resources. """) } @@ -253,7 +224,7 @@ public struct SettingsView: View { Image(systemName: "slider.horizontal.3") Text("Advanced") } - .badge(model.advancedSettings.isValid ? nil : "!") + .badge(model.settings.isValid ? nil : "!") logsTab .tabItem { Image(systemName: "doc.text") @@ -264,7 +235,7 @@ public struct SettingsView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { let action = ConfirmationAlertContinueAction.saveAllSettingsAndDismiss - if case .signedIn = model.tunnelAuthStatus { + if case .connected = model.tunnelStore.status { self.confirmationAlertContinueAction = action self.isShowingConfirmationAlert = true } else { @@ -272,7 +243,7 @@ public struct SettingsView: View { } } .disabled( - (model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid) + (model.settings == model.tunnelStore.settings || !model.settings.isValid) ) } ToolbarItem(placement: .navigationBarLeading) { @@ -347,8 +318,8 @@ public struct SettingsView: View { TextField( "Auth Base URL:", text: Binding( - get: { model.advancedSettings.authBaseURLString }, - set: { model.advancedSettings.authBaseURLString = $0 } + get: { model.settings.authBaseURL }, + set: { model.settings.authBaseURL = $0 } ), prompt: Text(PlaceholderText.authBaseURL) ) @@ -356,8 +327,8 @@ public struct SettingsView: View { TextField( "API URL:", text: Binding( - get: { model.advancedSettings.apiURLString }, - set: { model.advancedSettings.apiURLString = $0 } + get: { model.settings.apiURL }, + set: { model.settings.apiURL = $0 } ), prompt: Text(PlaceholderText.apiURL) ) @@ -365,8 +336,8 @@ public struct SettingsView: View { TextField( "Log Filter:", text: Binding( - get: { model.advancedSettings.connlibLogFilterString }, - set: { model.advancedSettings.connlibLogFilterString = $0 } + get: { model.settings.logFilter }, + set: { model.settings.logFilter = $0 } ), prompt: Text(PlaceholderText.logFilter) ) @@ -378,8 +349,8 @@ public struct SettingsView: View { Button( "Apply", action: { - let action = ConfirmationAlertContinueAction.saveAdvancedSettings - if case .signedIn = model.tunnelAuthStatus { + let action = ConfirmationAlertContinueAction.saveSettings + if [.connected, .connecting, .reasserting].contains(model.tunnelStore.status) { self.confirmationAlertContinueAction = action self.isShowingConfirmationAlert = true } else { @@ -387,15 +358,15 @@ public struct SettingsView: View { } } ) - .disabled(model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid) + .disabled(model.settings == model.tunnelStore.settings || !model.settings.isValid) Button( "Reset to Defaults", action: { - self.restoreAdvancedSettingsToDefaults() + model.settings = Settings.defaultValue } ) - .disabled(model.advancedSettings == AdvancedSettings.defaultValue) + .disabled(model.settings == Settings.defaultValue) } .padding(.top, 5) } @@ -416,8 +387,8 @@ public struct SettingsView: View { TextField( PlaceholderText.authBaseURL, text: Binding( - get: { model.advancedSettings.authBaseURLString }, - set: { model.advancedSettings.authBaseURLString = $0 } + get: { model.settings.authBaseURL }, + set: { model.settings.authBaseURL = $0 } ) ) .autocorrectionDisabled() @@ -431,8 +402,8 @@ public struct SettingsView: View { TextField( PlaceholderText.apiURL, text: Binding( - get: { model.advancedSettings.apiURLString }, - set: { model.advancedSettings.apiURLString = $0 } + get: { model.settings.apiURL }, + set: { model.settings.apiURL = $0 } ) ) .autocorrectionDisabled() @@ -446,8 +417,8 @@ public struct SettingsView: View { TextField( PlaceholderText.logFilter, text: Binding( - get: { model.advancedSettings.connlibLogFilterString }, - set: { model.advancedSettings.connlibLogFilterString = $0 } + get: { model.settings.logFilter }, + set: { model.settings.logFilter = $0 } ) ) .autocorrectionDisabled() @@ -459,10 +430,10 @@ public struct SettingsView: View { Button( "Reset to Defaults", action: { - self.restoreAdvancedSettingsToDefaults() + model.settings = Settings.defaultValue } ) - .disabled(model.advancedSettings == AdvancedSettings.defaultValue) + .disabled(model.settings == Settings.defaultValue) Spacer() } }, @@ -577,12 +548,12 @@ public struct SettingsView: View { #endif } - func saveAdvancedSettings() { - model.saveAdvancedSettings() + func saveSettings() { + model.saveSettings() } func saveAllSettingsAndDismiss() { - model.saveAdvancedSettings() + model.saveSettings() dismiss() } @@ -591,14 +562,6 @@ public struct SettingsView: View { dismiss() } - func restoreAdvancedSettingsToDefaults() { - let defaultValue = AdvancedSettings.defaultValue - model.advancedSettings.authBaseURLString = defaultValue.authBaseURLString - model.advancedSettings.apiURLString = defaultValue.apiURLString - model.advancedSettings.connlibLogFilterString = defaultValue.connlibLogFilterString - model.saveAdvancedSettings() - } - #if os(macOS) func exportLogsWithSavePanelOnMac() { self.isExportingLogs = true diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift index 6a598307d..90f2292f4 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift @@ -49,7 +49,8 @@ import SwiftUINavigationCore self.appStore = appStore self.settingsViewModel = appStore.settingsViewModel - let sessionNotificationHelper = SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore) + let sessionNotificationHelper = + SessionNotificationHelper(logger: appStore.logger, tunnelStore: appStore.tunnelStore) self.sessionNotificationHelper = sessionNotificationHelper appStore.objectWillChange @@ -58,28 +59,27 @@ import SwiftUINavigationCore .store(in: &cancellables) Publishers.CombineLatest( - appStore.authStore.$loginStatus, + appStore.tunnelStore.$status, sessionNotificationHelper.$notificationDecision ) .receive(on: mainQueue) - .sink(receiveValue: { [weak self] loginStatus, notificationDecision in - guard let self else { - return - } - switch (loginStatus, notificationDecision) { - case (.uninitialized, _), (_, .uninitialized): - self.state = .uninitialized - case (.needsTunnelCreationPermission, _), (_, .notDetermined): + .sink(receiveValue: { [weak self] status, notificationDecision in + guard let self = self else { return } + switch (status, notificationDecision) { + case (.invalid, _), (_, .notDetermined): self.state = .needsPermission( AskPermissionViewModel( tunnelStore: self.appStore.tunnelStore, - sessionNotificationHelper: self.sessionNotificationHelper + sessionNotificationHelper: sessionNotificationHelper ) ) - case (.signedOut, .determined): - self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore)) - case (.signedIn, .determined): - self.state = .authenticated(MainViewModel(appStore: self.appStore)) + case (.disconnected, .determined): + self.state = .unauthenticated(AuthViewModel(tunnelStore: appStore.tunnelStore)) + case (_, .determined): + self.state = .authenticated(MainViewModel(tunnelStore: appStore.tunnelStore, logger: appStore.logger)) + case (_, .uninitialized): + self.state = .uninitialized + } }) .store(in: &cancellables) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppLogger.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppLogger.swift index 226e92f9b..c7a4192fa 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppLogger.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppLogger.swift @@ -9,446 +9,128 @@ import Foundation import OSLog public final class AppLogger { - public enum Process: String { - case app - case tunnel + public enum Category: String, Codable { + case app = "app" + case tunnel = "tunnel" } private let logger: Logger - private let folderURL: URL? private let logWriter: LogWriter? - private let dateFormatter: DateFormatter - public init(process: Process, folderURL: URL?) { - let logger = Logger(subsystem: "dev.firezone.firezone", category: process.rawValue) - if folderURL == nil { - logger.log("AppLogger.init: folderURL is nil") - } - self.logger = logger - self.folderURL = folderURL - let target = LogWriter.Target(process: process) - self.logWriter = LogWriter(target: target, folderURL: folderURL, logger: self.logger) - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS Z" - self.dateFormatter = dateFormatter - - log("Starting logging") - - let appVersionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "Unknown" - log("Firezone \(appVersionString) (\(process.rawValue) process)") - - let osVersionString = ProcessInfo.processInfo.operatingSystemVersionString - #if os(iOS) - log("iOS \(osVersionString)") - #elseif os(macOS) - log("macOS \(osVersionString)") - #endif + public init(category: Category, folderURL: URL?) { + self.logger = Logger(subsystem: "dev.firezone.firezone", category: category.rawValue) + self.logWriter = LogWriter(category: category, folderURL: folderURL, logger: self.logger) } public func log(_ message: String) { - self.logger.log("\(message, privacy: .public)") - logWriter?.writeLogEntry(severity: .debug, message: message) + debug(message) + } + + public func trace(_ message: String) { + logger.trace("\(message, privacy: .public)") + logWriter?.write(severity: .trace, message: message) + } + + public func debug(_ message: String) { + self.logger.debug("\(message, privacy: .public)") + logWriter?.write(severity: .debug, message: message) + } + + public func info(_ message: String) { + logger.info("\(message, privacy: .public)") + logWriter?.write(severity: .info, message: message) + } + + public func warning(_ message: String) { + logger.warning("\(message, privacy: .public)") + logWriter?.write(severity: .warning, message: message) } public func error(_ message: String) { self.logger.error("\(message, privacy: .public)") - logWriter?.writeLogEntry(severity: .error, message: message) + logWriter?.write(severity: .error, message: message) } } private final class LogWriter { - struct DiskLog { - let logIndex: Int - let filePointer: UnsafeMutablePointer - let fileSizeAtOpen: UInt64 - } - - class MemoryLog { - var data = Data() - - func reset() { - self.data = Data() - } - } - enum Severity: String, Codable { + case trace = "TRACE" case debug = "DEBUG" + case info = "INFO" + case warning = "WARN" case error = "ERROR" } struct LogEntry: Codable { let time: String - let target: Target + let category: AppLogger.Category let severity: Severity let message: String } - private enum LogDestination { - // Normally, write log entries to disk - case disk(DiskLog?) - // While setting up the disk log, or when switching to - // a different log file, write to memory temporarily, - // so that it can be later written to disk - case memory(MemoryLog) - } - - enum Target: String, Codable { - case appMacOS = "app_macos" - case appiOS = "app_ios" - case tunnelMacOS = "tunnel_macos" - case tunneliOS = "tunnel_ios" - - init(process: AppLogger.Process) { - #if os(iOS) - switch process { - case .app: self = .appiOS - case .tunnel: self = .tunneliOS - } - #elseif os(macOS) - switch process { - case .app: self = .appMacOS - case .tunnel: self = .tunnelMacOS - } - #endif - } - } - - private let target: Target - private let folderURL: URL - private let logger: Logger - // All log writes happen in the workQueue private let workQueue: DispatchQueue - - // Switching between log files happen in the diskLogSwitchingQueue - private let diskLogSwitchingQueue: DispatchQueue - - private var logDestination: LogDestination - private var fileSizeAddendum: UInt64 = 0 + private let category: AppLogger.Category + private let logger: Logger + private let logFileURL: URL private let dateFormatter: ISO8601DateFormatter private let jsonEncoder: JSONEncoder - private let newlineData: Data - - private let logFileNameBase: String - private let currentIndexFileURL: URL - - private static let logFileNameExtension = "log" - private static let currentIndexFileName = "firezone_log_current_index" - private static let maxLogFileSize = 1024 * 1024 * 1024 // 1 MB - private static let maxLogFilesCount = 5 - - init?(target: Target, folderURL: URL?, logger: Logger) { - guard let folderURL = folderURL else { - logger.error("LogWriter.init: folderURL is nil") - return nil - } - - self.target = target - self.folderURL = folderURL - self.logger = logger - self.logFileNameBase = target.rawValue - self.currentIndexFileURL = self.folderURL.appendingPathComponent(Self.currentIndexFileName) - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] - self.dateFormatter = dateFormatter - - let jsonEncoder = JSONEncoder() - jsonEncoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - self.jsonEncoder = jsonEncoder - - self.newlineData = "\n".data(using: .utf8)! - - workQueue = DispatchQueue(label: "LogWriter.workQueue", qos: .utility) - diskLogSwitchingQueue = DispatchQueue( - label: "LogWriter.diskLogSwitchingQueue", qos: .background) - - self.logger.error("LogWriter.init: Temporarily using memory log") - logDestination = .memory(MemoryLog()) - - performInitialSetup() - } - - private func performInitialSetup() { - diskLogSwitchingQueue.async { - let currentIndex: Int = { - let currentIndexFromFile: Int? = { - guard let currentIndexString = try? String(contentsOf: self.currentIndexFileURL) else { - return nil - } - return Int(currentIndexString) - }() - if let currentIndexFromFile = currentIndexFromFile { - self.logger.error( - "LogWriter.performInitialSetup: Current log index read from file: \(currentIndexFromFile, privacy: .public)" - ) - return currentIndexFromFile - } else { - self.logger.error( - "LogWriter.performInitialSetup: Current log index could not be read from file. Assuming current log index as 0." - ) - try? "0".write(to: self.currentIndexFileURL, atomically: true, encoding: .utf8) - return 0 - } - }() - - let diskLog = Self.openDiskLog( - folderURL: self.folderURL, logFileNameBase: self.logFileNameBase, - logIndex: currentIndex, shouldRemoveExistingFile: false, - logger: self.logger - ) - - if diskLog == nil { - self.logger.error( - "LogWriter.performInitialSetup: Unable to switch log. Log entries will not be written to disk." - ) - } - - self.workQueue.async { - if case .memory(let memoryLog) = self.logDestination { - let bytesWritten = - Self.writeMemoryLogToDisk(memoryLog: memoryLog, diskLog: diskLog, logger: self.logger) - self.fileSizeAddendum = UInt64(bytesWritten) - self.logDestination = .disk(diskLog) - self.logger.error( - "LogWriter.performInitialSetup: Switched to disk log." - ) - } - } - } - } - - private static func openDiskLog( - folderURL: URL, logFileNameBase: String, - logIndex: Int, shouldRemoveExistingFile: Bool, logger: Logger - ) -> DiskLog? { + init?(category: AppLogger.Category, folderURL: URL?, logger: Logger) { let fileManager = FileManager.default + let dateFormatter = ISO8601DateFormatter() + let jsonEncoder = JSONEncoder() + dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] + jsonEncoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - // Look for an existing log file with this log index - var existingLogFiles: [URL] = [] - if let enumerator = fileManager.enumerator( - at: folderURL, - includingPropertiesForKeys: [.isRegularFileKey, .nameKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants, .skipsSubdirectoryDescendants], - errorHandler: nil - ) { - for item in enumerator.enumerated() { - guard let url = item.element as? URL else { continue } - do { - let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .nameKey]) - if resourceValues.isRegularFile ?? false { - if let fileName = resourceValues.name { - if fileName.hasPrefix("\(logFileNameBase).") - && fileName.hasSuffix(".\(logIndex).\(Self.logFileNameExtension)") - { - existingLogFiles.append(url) - } - } - } - } catch { - logger.error( - "LogWriter.openDiskLog: Unable to get resource value for '\(url.path, privacy: .public)': \(error, privacy: .public)" - ) - } - } - } + self.dateFormatter = dateFormatter + self.jsonEncoder = jsonEncoder + self.logger = logger + self.category = category - if !shouldRemoveExistingFile && existingLogFiles.count > 0 { - // Open the existing log file in append mode - if existingLogFiles.count > 1 { - // In case there are multiple log files at this index (there shouldn't be), - // pick something predictably, so we pick the same one each time. - existingLogFiles.sort(by: { $0.lastPathComponent > $1.lastPathComponent }) - } - - let existingLogFile = existingLogFiles.first! - let existingLogFilePath = existingLogFile.path - - logger.error( - "LogWriter.openDiskLog: File exists at '\(existingLogFilePath, privacy: .public)'" - ) - var fileSize: UInt64? = nil - do { - let attr = try fileManager.attributesOfItem(atPath: existingLogFilePath) - fileSize = attr[FileAttributeKey.size] as? UInt64 - } catch { - logger.error( - "LogWriter.openDiskLog: Error getting file attributes of '\(existingLogFilePath, privacy: .public)': \(error, privacy: .public)" - ) - return nil - } - if let fileSize = fileSize { - if let filePointer = fopen(existingLogFilePath, "a") { - return DiskLog(logIndex: logIndex, filePointer: filePointer, fileSizeAtOpen: fileSize) - } else { - logger.error( - "LogWriter.openDiskLog: Can't open file '\(existingLogFilePath, privacy: .public)' for appending" - ) - return nil - } - } else { - logger.error( - "LogWriter.openDiskLog: Can't figure out file size for '\(existingLogFilePath, privacy: .public)'" - ) - return nil - } - } - - if shouldRemoveExistingFile { - // Remove the existing log file - for existingLogFile in existingLogFiles { - // In case there are multiple log files at this index (there shouldn't be), - // remove all of them. - do { - logger.error( - "LogWriter.openDiskLog: Removing file at '\(existingLogFile.path, privacy: .public)'" - ) - try fileManager.removeItem(at: existingLogFile) - } catch { - logger.error( - "LogWriter.openDiskLog: Error removing file '\(existingLogFile.path, privacy: .public)'" - ) - } - } - } - - // There's no log file at this log index. Create a new log file in write mode. - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" - let timestamp = dateFormatter.string(from: Date()) - let logFileURL = folderURL.appendingPathComponent( - "\(logFileNameBase).\(timestamp).\(logIndex).\(Self.logFileNameExtension)") - let logFilePath = logFileURL.path - logger.error("LogWriter.openDiskLog: Creating file at '\(logFilePath, privacy: .public)'") - if let filePointer = fopen(logFilePath, "w") { - return DiskLog(logIndex: logIndex, filePointer: filePointer, fileSizeAtOpen: 0) - } else { - logger.error( - "LogWriter.openDiskLog: Can't open file '\(logFilePath, privacy: .public)' for writing" - ) + // Create log dir if not exists + guard let folderURL = folderURL, + SharedAccess.ensureDirectoryExists(at: folderURL.path) + else { + logger.error("Log directory isn't acceptable!") return nil } + self.logFileURL = folderURL + .appendingPathComponent(dateFormatter.string(from: Date())) + .appendingPathExtension("log") + + // Create log file + guard fileManager.createFile(atPath: self.logFileURL.path, contents: "".data(using: .utf8)) + else { + logger.error("Could not create log file: \(self.logFileURL.path)") + return nil + } + + self.workQueue = DispatchQueue(label: "LogWriter.workQueue", qos: .utility) } - func writeLogEntry(severity: Severity, message: String) { + func write(severity: Severity, message: String) { let logEntry = LogEntry( - time: self.dateFormatter.string(from: Date()), - target: self.target, + time: dateFormatter.string(from: Date()), + category: category, severity: severity, message: message) - var jsonData = Data() - do { - jsonData = try jsonEncoder.encode(logEntry) - jsonData.append(self.newlineData) - } catch { - self.logger.error( - "LogWriter.writeLogEntry: Error encoding log entry to JSON: \(error, privacy: .public)" - ) + + guard var jsonData = try? jsonEncoder.encode(logEntry), + let newLineData = "\n".data(using: .utf8) + else { + logger.error("Could not encode log message to JSON!") + return } + jsonData.append(newLineData) + workQueue.async { - switch self.logDestination { - case .disk(let diskLogNullable): - guard let diskLog = diskLogNullable else { return } - var bytesWritten = 0 - do { - bytesWritten = try Self.writeDataToDisk(data: jsonData, diskLog: diskLog) - } catch { - self.logger.error( - "LogWriter.writeLogEntry: Error writing log entry to disk: \(error, privacy: .public)" - ) - } - self.fileSizeAddendum += UInt64(bytesWritten) - - if (diskLog.fileSizeAtOpen + self.fileSizeAddendum) > Self.maxLogFileSize { - let memoryLog = MemoryLog() - self.logDestination = .memory(memoryLog) - self.fileSizeAddendum = 0 - self.logger.error("LogWriter.writeLogEntry: Temporarily using memory log") - - self.diskLogSwitchingQueue.async { - fclose(diskLog.filePointer) - let nextLogIndex = (diskLog.logIndex + 1) % Self.maxLogFilesCount - let nextDiskLogNullable = Self.openDiskLog( - folderURL: self.folderURL, logFileNameBase: self.logFileNameBase, - logIndex: nextLogIndex, - shouldRemoveExistingFile: true, logger: self.logger - ) - - guard let nextDiskLog = nextDiskLogNullable else { - self.logger.error( - "LogWriter.writeLogEntry: Unable to switch log. Log entries will not be written to disk." - ) - self.workQueue.async { - self.logDestination = .disk(nil) - } - return - } - - do { - try "\(nextLogIndex)" - .write(to: self.currentIndexFileURL, atomically: true, encoding: .utf8) - } catch { - self.logger.error( - "LogWriter.writeLogEntry: Error writing current index as '\(nextLogIndex)' to disk: \(error, privacy: .public)" - ) - return - } - - self.workQueue.async { - let bytesWritten = Self.writeMemoryLogToDisk( - memoryLog: memoryLog, diskLog: nextDiskLog, logger: self.logger) - self.fileSizeAddendum = UInt64(bytesWritten) - self.logDestination = .disk(nextDiskLog) - self.logger.error("LogWriter.writeLogEntry: Switched to disk log") - } - } - } - case .memory(let memoryLog): - memoryLog.data.append(jsonData) + do { + try jsonData.write(to: self.logFileURL, options: .atomic) + } catch { + self.logger.error("Could not write LogEntry! \(error)") } } } - - private static func writeMemoryLogToDisk(memoryLog: MemoryLog, diskLog: DiskLog?, logger: Logger) - -> Int - { - guard let diskLog = diskLog else { - logger.error("LogWriter.writeMemoryLogToDisk: diskLog is nil") - return 0 - } - - guard memoryLog.data.count > 0 else { - return 0 - } - - do { - logger.log( - "LogWriter.writeMemoryLogToDisk: Writing memory log to disk (\(memoryLog.data.count, privacy: .public) bytes)" - ) - return try writeDataToDisk(data: memoryLog.data, diskLog: diskLog) - } catch { - logger.error( - "LogWriter.writeMemoryLogToDisk: Error writing memory log to disk: \(error, privacy: .public)" - ) - } - - return 0 - } - - private static func writeDataToDisk(data: Data, diskLog: DiskLog) throws -> Int { - var bytesWritten = 0 - if data.count > 0 { - bytesWritten = try data.withUnsafeBytes { pointer -> Int in - fwrite( - pointer.baseAddress, MemoryLayout.size, data.count, - diskLog.filePointer - ) - } - fflush(diskLog.filePointer) - } - return bytesWritten - } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift index b90bb8a71..d5c424749 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift @@ -41,7 +41,7 @@ public class SessionNotificationHelper: NSObject { } private let logger: AppLogger - private let authStore: AuthStore + private let tunnelStore: TunnelStore @Published var notificationDecision: NotificationDecision = .uninitialized { didSet { @@ -51,10 +51,10 @@ public class SessionNotificationHelper: NSObject { } } - public init(logger: AppLogger, authStore: AuthStore) { + public init(logger: AppLogger, tunnelStore: TunnelStore) { self.logger = logger - self.authStore = authStore + self.tunnelStore = tunnelStore super.init() @@ -145,7 +145,7 @@ public class SessionNotificationHelper: NSObject { #elseif os(macOS) // In macOS, use a Cocoa alert. // This gets called from the app side. - static func showSignedOutAlertmacOS(logger: AppLogger, authStore: AuthStore) { + static func showSignedOutAlertmacOS(logger: AppLogger, tunnelStore: TunnelStore) { let alert = NSAlert() alert.messageText = "Your Firezone session has ended" alert.informativeText = "Please sign in again to reconnect" @@ -157,7 +157,7 @@ public class SessionNotificationHelper: NSObject { logger.log("SessionNotificationHelper: \(#function): 'Sign In' clicked in notification") Task { do { - try await authStore.signIn() + try await tunnelStore.signIn() } catch { logger.error("Error signing in: \(error)") } @@ -178,7 +178,7 @@ public class SessionNotificationHelper: NSObject { // User clicked on 'Sign In' in the notification Task { do { - try await self.authStore.signIn() + try await tunnelStore.signIn() } catch { self.logger.error("Error signing in: \(error)") } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift index 9d17440ea..e5b5f97f2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift @@ -69,11 +69,11 @@ public struct SharedAccess { return nil } - public static var tunnelShutdownEventFileURL: URL { - baseFolderURL.appendingPathComponent("tunnel_shutdown_event_data.json") + public static var providerStopReasonURL: URL { + baseFolderURL.appendingPathComponent("reason") } - private static func ensureDirectoryExists(at path: String) -> Bool { + public static func ensureDirectoryExists(at path: String) -> Bool { let fileManager = FileManager.default do { var isDirectory: ObjCBool = false diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift deleted file mode 100644 index 5e92b0948..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// DisconnectReason.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Foundation -import NetworkExtension -import os - -enum TunnelShutdownEventError: Error { - case decodeError - case cannotGetFileURL -} - -public struct TunnelShutdownEvent: Codable, CustomStringConvertible { - public enum Reason: Codable, CustomStringConvertible { - case stopped(NEProviderStopReason) - case connlibConnectFailure - case connlibDisconnected - case badTunnelConfiguration - case tokenNotFound - - public var description: String { - switch self { - case .stopped(let reason): return "stopped(reason code: \(reason.rawValue))" - case .connlibConnectFailure: return "connlib connection failure" - case .connlibDisconnected: return "connlib disconnected" - case .badTunnelConfiguration: return "bad tunnel configuration" - case .tokenNotFound: return "token not found" - } - } - - public var action: Action { - switch self { - case .stopped(let reason): - if reason == .userInitiated { - return .signoutImmediatelySilently - } else { - return .doNothing - } - case .connlibConnectFailure, .connlibDisconnected, - .badTunnelConfiguration, .tokenNotFound: - return .signoutImmediately - } - } - } - - public enum Action { - case doNothing - case signoutImmediately - case signoutImmediatelySilently - } - - public let reason: TunnelShutdownEvent.Reason - public let errorMessage: String - public let date: Date - - public var action: Action { reason.action } - - public var description: String { - "(\(reason)\(action == .signoutImmediately ? " (needs immediate signout)" : ""), error: '\(errorMessage)', date: \(date))" - } - - public init(reason: TunnelShutdownEvent.Reason, errorMessage: String) { - self.reason = reason - self.errorMessage = errorMessage - self.date = Date() - } - - public static func loadFromDisk(logger: AppLogger) -> TunnelShutdownEvent? { - let fileURL = SharedAccess.tunnelShutdownEventFileURL - let fileManager = FileManager.default - - guard fileManager.fileExists(atPath: fileURL.path) else { - return nil - } - - guard let jsonData = try? Data(contentsOf: fileURL) else { - logger.error("Could not read tunnel shutdown event from disk at: \(fileURL)") - return nil - } - - guard let reason = try? JSONDecoder().decode(TunnelShutdownEvent.self, from: jsonData) else { - logger.error("Error decoding tunnel shutdown event from disk at: \(fileURL)") - return nil - } - - do { - try fileManager.removeItem(atPath: fileURL.path) - } catch { - logger.error("Cannot remove tunnel shutdown event file at \(fileURL.path)") - } - - return reason - } - - public static func saveToDisk( - reason: TunnelShutdownEvent.Reason, errorMessage: String, logger: AppLogger - ) { - let fileURL = SharedAccess.tunnelShutdownEventFileURL - logger.error("Saving tunnel shutdown event data to \(fileURL)") - let tsEvent = TunnelShutdownEvent( - reason: reason, - errorMessage: errorMessage) - do { - try JSONEncoder().encode(tsEvent).write(to: fileURL) - } catch { - logger.error( - "Error writing tunnel shutdown event data to disk to: \(fileURL): \(error)" - ) - } - } -} - -extension NEProviderStopReason: Codable { -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift index 4ed0d65fb..b184db952 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift @@ -7,7 +7,7 @@ import Foundation public enum KeychainError: Error { - case securityError(Status) + case securityError(KeychainStatus) case appleSecError(call: String, status: Keychain.SecStatus) case nilResultFromAppleSecCall(call: String) case resultFromAppleSecCallIsInvalid(call: String) @@ -18,23 +18,22 @@ public enum KeychainError: Error { } public actor Keychain { - private static let account = "Firezone" + private let label = "Firezone token" + private let description = "Firezone access token used to authenticate the client." + private let account = "Firezone" + private let service = Bundle.main.bundleIdentifier! + private let workQueue = DispatchQueue(label: "FirezoneKeychainWorkQueue") public typealias Token = String public typealias PersistentRef = Data - public struct TokenAttributes { - let authBaseURLString: String - let actorName: String - } - public enum SecStatus: Equatable { - case status(Status) + case status(KeychainStatus) case unknownStatus(OSStatus) init(_ osStatus: OSStatus) { - if let status = Status(rawValue: osStatus) { + if let status = KeychainStatus(rawValue: osStatus) { self = .status(status) } else { self = .unknownStatus(osStatus) @@ -48,42 +47,27 @@ public actor Keychain { public init() {} - func store(token: Token, tokenAttributes: TokenAttributes) async throws -> PersistentRef { - #if os(iOS) - let query = - [ - // Common for both iOS and macOS: - kSecClass: kSecClassGenericPassword, - kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))", - kSecAttrDescription: "Firezone access token", - kSecAttrService: tokenAttributes.authBaseURLString, - // The UUID uniquifies this item in the keychain - kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", - kSecValueData: token.data(using: .utf8) as Any, - kSecReturnPersistentRef: true, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, - // Specific to iOS: - kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any, - ] as [CFString: Any] - #elseif os(macOS) - let query = - [ - // Common for both iOS and macOS: - kSecClass: kSecClassGenericPassword, - kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))", - kSecAttrDescription: "Firezone access token", - kSecAttrService: tokenAttributes.authBaseURLString, - // The UUID uniquifies this item in the keychain - kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", - kSecValueData: token.data(using: .utf8) as Any, - kSecReturnPersistentRef: true, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, - // Specific to macOS: - kSecAttrAccess: try secAccessForAppAndNetworkExtension(), - ] as [CFString: Any] - #endif + func add(token: Token) async throws -> PersistentRef { return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.workQueue.async { + self?.workQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: KeychainError.securityError(.unexpectedError)) + return + } + + let query: [CFString: Any] = [ + // Common for both iOS and macOS: + kSecClass: kSecClassGenericPassword, + kSecAttrLabel: label, + kSecAttrDescription: description, + kSecAttrAccount: account, + kSecAttrService: service, + kSecValueData: token.data(using: .utf8) as Any, + kSecReturnPersistentRef: true, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any, + ] + var ref: CFTypeRef? let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref)) guard ret.isSuccess else { @@ -91,85 +75,45 @@ public actor Keychain { throwing: KeychainError.appleSecError(call: "SecItemAdd", status: ret)) return } + guard let savedPersistentRef = ref as? Data else { continuation.resume(throwing: KeychainError.nilResultFromAppleSecCall(call: "SecItemAdd")) return } - // Remove any other keychain items for the same service URL - var checkForStaleItemsResult: CFTypeRef? - let checkForStaleItemsQuery = - [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: tokenAttributes.authBaseURLString, - kSecMatchLimit: kSecMatchLimitAll, - kSecReturnPersistentRef: true, - ] as [CFString: Any] - let checkRet = - SecStatus( - SecItemCopyMatching(checkForStaleItemsQuery as CFDictionary, &checkForStaleItemsResult)) - var isSavedItemFound = false - if checkRet.isSuccess, let allRefs = checkForStaleItemsResult as? [Data] { - for ref in allRefs { - if ref == savedPersistentRef { - isSavedItemFound = true - } else { - SecItemDelete([kSecValuePersistentRef: ref] as CFDictionary) - } - } - } - guard isSavedItemFound else { - continuation.resume(throwing: KeychainError.unableToFindSavedItem) - return - } continuation.resume(returning: savedPersistentRef) + return } } } - #if os(macOS) - private func secAccessForAppAndNetworkExtension() throws -> SecAccess { - // Creating a trusted-application-based SecAccess APIs are deprecated in favour of - // data-protection keychain APIs. However, data-protection keychain doesn't support - // accessing from non-userspace processes, like the tunnel process, so we can only - // use the deprecated APIs for now. - func secTrustedApplicationForPath(_ path: String?) throws -> SecTrustedApplication? { - var trustedApp: SecTrustedApplication? - let ret = SecStatus(SecTrustedApplicationCreateFromPath(path, &trustedApp)) + func update(token: Token) async throws { + + return try await withCheckedThrowingContinuation { [weak self] continuation in + self?.workQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: KeychainError.securityError(.unexpectedError)) + return + } + + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: self.account, + kSecAttrService: self.service, + ] + let attributesToUpdate = [ + kSecValueData: token.data(using: .utf8) as Any + ] + let ret = SecStatus( + SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)) guard ret.isSuccess else { - throw KeychainError.appleSecError( - call: "SecTrustedApplicationCreateFromPath", status: ret) + continuation.resume( + throwing: KeychainError.appleSecError(call: "SecItemUpdate", status: ret)) + return } - if let trustedApp = trustedApp { - return trustedApp - } else { - throw KeychainError.nilResultFromAppleSecCall( - call: "SecTrustedApplicationCreateFromPath(\(path ?? "nil"))") - } - } - guard let pluginsURL = Bundle.main.builtInPlugInsURL else { - throw KeychainError.unableToGetPluginsPath - } - let extensionPath = - pluginsURL - .appendingPathComponent("FirezoneNetworkExtensionmacOS.appex", isDirectory: true) - .path - let trustedApps = [ - try secTrustedApplicationForPath(nil), - try secTrustedApplicationForPath(extensionPath), - ] - var access: SecAccess? - let ret = SecStatus( - SecAccessCreate("Firezone Token" as CFString, trustedApps as CFArray, &access)) - guard ret.isSuccess else { - throw KeychainError.appleSecError(call: "SecAccessCreate", status: ret) - } - if let access = access { - return access - } else { - throw KeychainError.nilResultFromAppleSecCall(call: "SecAccessCreate") + continuation.resume() } } - #endif + } // This function is public because the tunnel needs to call it to get the token public func load(persistentRef: PersistentRef) async -> Token? { @@ -194,64 +138,16 @@ public actor Keychain { } } - func loadAttributes(persistentRef: PersistentRef) async -> TokenAttributes? { + func search() async -> PersistentRef? { return await withCheckedContinuation { [weak self] continuation in - self?.workQueue.async { - let query = - [ - kSecValuePersistentRef: persistentRef, - kSecReturnAttributes: true, - ] as [CFString: Any] - var result: CFTypeRef? - let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result)) - if ret.isSuccess, let result = result { - if CFGetTypeID(result) == CFDictionaryGetTypeID() { - let cfDict = result as! CFDictionary - let dict = cfDict as NSDictionary - if let service = dict[kSecAttrService] as? String, - let account = dict[kSecAttrAccount] as? String - { - let actorName = String( - account[ - account - .startIndex..<(account.lastIndex(of: ":") - ?? account.endIndex)]) - let attributes = TokenAttributes( - authBaseURLString: service, - actorName: actorName) - continuation.resume(returning: attributes) - return - } - } - } - continuation.resume(returning: nil) - } - } - } - - func delete(persistentRef: PersistentRef) async throws { - return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.workQueue.async { - let query = [kSecValuePersistentRef: persistentRef] as [CFString: Any] - let ret = SecStatus(SecItemDelete(query as CFDictionary)) - guard ret.isSuccess || ret == .status(.itemNotFound) else { - continuation.resume( - throwing: KeychainError.appleSecError(call: "SecItemDelete", status: ret)) - return - } - continuation.resume(returning: ()) - } - } - } - - func search(authBaseURLString: String) async -> PersistentRef? { - return await withCheckedContinuation { [weak self] continuation in - self?.workQueue.async { + guard let self = self else { return } + self.workQueue.async { let query = [ kSecClass: kSecClassGenericPassword, - kSecAttrDescription: "Firezone access token", - kSecAttrService: authBaseURLString, + kSecAttrAccount: self.account, + kSecAttrDescription: self.description, + kSecAttrService: self.service, kSecReturnPersistentRef: true, ] as [CFString: Any] var result: CFTypeRef? @@ -266,6 +162,6 @@ public actor Keychain { } private func securityError(_ status: OSStatus) -> Error { - KeychainError.securityError(Status(rawValue: status)!) + KeychainError.securityError(KeychainStatus(rawValue: status)!) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Status.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStatus.swift similarity index 98% rename from swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Status.swift rename to swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStatus.swift index 1376d3d51..f8fc8628a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Status.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStatus.swift @@ -10,7 +10,7 @@ import Foundation -public enum Status: OSStatus, Error { +public enum KeychainStatus: OSStatus, Error { case success = 0 case unimplemented = -4 case diskFull = -34 @@ -416,12 +416,12 @@ public enum Status: OSStatus, Error { case unexpectedError = -99999 } -extension Status { - static func == (lhs: OSStatus, rhs: Status) -> Bool { +extension KeychainStatus { + static func == (lhs: OSStatus, rhs: KeychainStatus) -> Bool { lhs == rhs.rawValue } - static func != (lhs: OSStatus, rhs: Status) -> Bool { + static func != (lhs: OSStatus, rhs: KeychainStatus) -> Bool { lhs != rhs.rawValue } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift index 4e386a7d8..1df518508 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift @@ -8,11 +8,9 @@ import Dependencies import Foundation struct KeychainStorage: Sendable { - var store: - @Sendable (Keychain.Token, Keychain.TokenAttributes) async throws -> Keychain.PersistentRef - var delete: @Sendable (Keychain.PersistentRef) async throws -> Void - var loadAttributes: @Sendable (Keychain.PersistentRef) async -> Keychain.TokenAttributes? - var searchByAuthBaseURL: @Sendable (URL) async -> Keychain.PersistentRef? + var add: @Sendable (Keychain.Token) async throws -> Keychain.PersistentRef + var update: @Sendable (Keychain.Token) async throws -> Void + var search: @Sendable () async -> Keychain.PersistentRef? } extension KeychainStorage: DependencyKey { @@ -20,33 +18,27 @@ extension KeychainStorage: DependencyKey { let keychain = Keychain() return KeychainStorage( - store: { try await keychain.store(token: $0, tokenAttributes: $1) }, - delete: { try await keychain.delete(persistentRef: $0) }, - loadAttributes: { await keychain.loadAttributes(persistentRef: $0) }, - searchByAuthBaseURL: { await keychain.search(authBaseURLString: $0.absoluteString) } + add: { try await keychain.add(token: $0) }, + update: { try await keychain.update(token: $0) }, + search: { await keychain.search() } ) } static var testValue: KeychainStorage { - let storage = LockIsolated([Data: (Keychain.Token, Keychain.TokenAttributes)]()) + let storage = LockIsolated([Data: (Keychain.Token)]()) return KeychainStorage( - store: { token, attributes in + add: { token in storage.withValue { let uuid = UUID().uuidString.data(using: .utf8)! - $0[uuid] = (token, attributes) + $0[uuid] = (token) return uuid } }, - delete: { ref in - storage.withValue { - $0[ref] = nil - } + update: { token in + }, - loadAttributes: { ref in - storage.value[ref]?.1 - }, - searchByAuthBaseURL: { _ in - nil + search: { + return UUID().uuidString.data(using: .utf8)! } ) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/DisplayableResources.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/DisplayableResources.swift deleted file mode 100644 index 1b481ab9d..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/DisplayableResources.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// DisplayableResources.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -// This models resources that are displayed in the UI - -import Foundation - -public class DisplayableResources { - - public struct Resource: Identifiable { - public var id: String { location } - public let name: String - public let location: String - - public init(name: String, location: String) { - self.name = name - self.location = location - } - } - - public private(set) var version: UInt64 - public private(set) var versionString: String - public private(set) var orderedResources: [Resource] - - public init(version: UInt64, resources: [Resource]) { - self.version = version - self.versionString = "\(version)" - self.orderedResources = resources - } - - public convenience init() { - self.init(version: 0, resources: []) - } - - public func update(resources: [Resource]) { - self.version = self.version &+ 1 // Overflow is ok - self.versionString = "\(version)" - self.orderedResources = resources - } -} - -extension DisplayableResources { - public func toData() -> Data? { - ("\(versionString)," - + (orderedResources.flatMap { [$0.name, $0.location] }) - .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) }.compactMap { $0 } - .joined(separator: ",")).data(using: .utf8) - } - - public convenience init?(from data: Data) { - guard let components = String(data: data, encoding: .utf8)?.split(separator: ",") else { - return nil - } - guard let versionString = components.first, let version = UInt64(versionString) else { - return nil - } - var resources: [Resource] = [] - for index in stride(from: 2, to: components.count, by: 2) { - guard let name = components[index - 1].removingPercentEncoding, - let location = components[index].removingPercentEncoding - else { - continue - } - resources.append(Resource(name: name, location: location)) - } - self.init(version: version, resources: resources) - } - - public func versionStringToData() -> Data { - versionString.data(using: .utf8)! - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneError.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneError.swift deleted file mode 100644 index f69155ffe..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneError.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// FirezoneError.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Foundation - -enum FirezoneError: Error { -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift new file mode 100644 index 000000000..c3577052f --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift @@ -0,0 +1,23 @@ +// +// Resource.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +// This models resources that are displayed in the UI + +import Foundation + +public struct Resource: Decodable, Identifiable { + public let id: String + public var name: String + public var address: String + public var type: String + + public init(id: String, name: String, address: String, type: String) { + self.id = id + self.name = name + self.address = address + self.type = type + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index 90f4830f1..e55bb4a54 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -6,48 +6,72 @@ import Foundation -struct AdvancedSettings: Equatable { - var authBaseURLString: String { - didSet { if oldValue != authBaseURLString { isSavedToDisk = false } } - } - var apiURLString: String { - didSet { if oldValue != apiURLString { isSavedToDisk = false } } - } - var connlibLogFilterString: String { - didSet { if oldValue != connlibLogFilterString { isSavedToDisk = false } } - } - - var isSavedToDisk = true +struct Settings: Equatable { + var authBaseURL: String + var apiURL: String + var logFilter: String var isValid: Bool { - URL(string: authBaseURLString) != nil - && URL(string: apiURLString) != nil - && !connlibLogFilterString.isEmpty + 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 } - static let defaultValue: AdvancedSettings = { + // Convert provider configuration (which may have empty fields if it was tampered with) to Settings + static func fromProviderConfiguration(providerConfiguration: [String: String]?) -> Settings { + if let providerConfiguration = providerConfiguration { + return Settings( + authBaseURL: providerConfiguration[TunnelStoreKeys.authBaseURL] + ?? Settings.defaultValue.authBaseURL, + apiURL: providerConfiguration[TunnelStoreKeys.apiURL] ?? Settings.defaultValue.apiURL, + logFilter: providerConfiguration[TunnelStoreKeys.logFilter] + ?? Settings.defaultValue.logFilter + ) + } else { + return Settings.defaultValue + } + } + + // Used for initializing a new providerConfiguration from Settings + func toProviderConfiguration() -> [String: String] { + return [ + "authBaseURL": authBaseURL, + "apiURL": apiURL, + "logFilter": logFilter, + ] + } + + 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 - AdvancedSettings( - authBaseURLString: "https://app.firez.one/", - apiURLString: "wss://api.firez.one/", - connlibLogFilterString: - "firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,str0m=info,debug" + Settings( + authBaseURL: "https://app.firez.one", + apiURL: "wss://api.firez.one", + logFilter: + "firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn" ) #else - AdvancedSettings( - authBaseURLString: "https://app.firezone.dev/", - apiURLString: "wss://api.firezone.dev/", - connlibLogFilterString: "info" - ) + Settings( + authBaseURL: "https://app.firezone.dev", + apiURL: "wss://api.firezone.dev", + logFilter: "info" + ) #endif }() - - // Note: To see what the connlibLogFilterString values mean, see: - // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html } -extension AdvancedSettings: CustomStringConvertible { +extension Settings: CustomStringConvertible { var description: String { - "(\(authBaseURLString), \(apiURLString), \(connlibLogFilterString))" + "(\(authBaseURL), \(apiURL), \(logFilter)" } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift index 9f0d18749..a6f2ac7d7 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift @@ -54,14 +54,9 @@ public final class AppStore: ObservableObject { return false } } - - public static func allIdentifiers() -> [String] { - AppStore.WindowDefinition.allCases.map { $0.identifier } - } } #endif - public let authStore: AuthStore public let tunnelStore: TunnelStore public let settingsViewModel: SettingsViewModel @@ -69,41 +64,29 @@ public final class AppStore: ObservableObject { public let logger: AppLogger public init() { - let logger = AppLogger(process: .app, folderURL: SharedAccess.appLogFolderURL) + let logger = AppLogger(category: .app, folderURL: SharedAccess.appLogFolderURL) let tunnelStore = TunnelStore(logger: logger) - let authStore = AuthStore(tunnelStore: tunnelStore, logger: logger) - let settingsViewModel = SettingsViewModel(authStore: authStore, logger: logger) + let settingsViewModel = SettingsViewModel(tunnelStore: tunnelStore, logger: logger) - self.authStore = authStore self.tunnelStore = tunnelStore self.settingsViewModel = settingsViewModel self.logger = logger #if os(macOS) - tunnelStore.$tunnelAuthStatus - .sink { tunnelAuthStatus in - - if case .noTunnelFound = tunnelAuthStatus { - Task { - await MainActor.run { + tunnelStore.$status + .sink { status in + Task { + await MainActor.run { + // FIXME: Clean up Swift UI window groups to use a multi-step wizard + if case .invalid = status { WindowDefinition.askPermission.openWindow() + } else if !tunnelStore.firstTime() { + WindowDefinition.askPermission.window()?.close() } } } } .store(in: &cancellables) #endif - - } - - private func signOutAndStopTunnel() { - Task { - do { - try await self.tunnelStore.stop() - await self.authStore.signOut() - } catch { - logger.error("\(#function): Error stopping tunnel: \(String(describing: error))") - } - } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift deleted file mode 100644 index 9a4a5a09e..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// AuthStore.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Combine -import Dependencies -import Foundation -import NetworkExtension -import OSLog - -#if os(macOS) - import AppKit -#endif - -@MainActor -public final class AuthStore: ObservableObject { - enum LoginStatus: CustomStringConvertible { - case uninitialized - case needsTunnelCreationPermission - case signedOut - case signedIn(actorName: String) - - var description: String { - switch self { - case .uninitialized: - return "uninitialized" - case .needsTunnelCreationPermission: - return "needsTunnelCreationPermission" - case .signedOut: - return "signedOut" - case .signedIn(let actorName): - return "signedIn(actorName: \(actorName))" - } - } - } - - @Dependency(\.keychain) private var keychain - @Dependency(\.auth) private var auth - @Dependency(\.mainQueue) private var mainQueue - - let tunnelStore: TunnelStore - - private let logger: AppLogger - private var cancellables = Set() - - @Published private(set) var loginStatus: LoginStatus { - didSet { - self.handleLoginStatusChanged() - } - } - - private var status: NEVPNStatus = .invalid - - // Try to automatically reconnect on network changes - private static let maxReconnectionAttemptCount = 60 - private let reconnectDelaySecs = 1 - private var reconnectionAttemptsRemaining = maxReconnectionAttemptCount - - init(tunnelStore: TunnelStore, logger: AppLogger) { - self.tunnelStore = tunnelStore - self.logger = logger - self.loginStatus = .uninitialized - - Task { - self.loginStatus = await self.getLoginStatus(from: tunnelStore.tunnelAuthStatus) - } - - tunnelStore.$tunnelAuthStatus - .receive(on: mainQueue) - .sink { [weak self] tunnelAuthStatus in - guard let self = self else { return } - logger.log("Tunnel auth status changed to: \(tunnelAuthStatus)") - self.upateLoginStatus() - } - .store(in: &cancellables) - - tunnelStore.$status - .sink { [weak self] status in - guard let self = self else { return } - Task { - if status == .disconnected { - self.handleTunnelDisconnectionEvent() - } - self.status = status - } - } - .store(in: &cancellables) - } - - private var authBaseURL: URL { - if let advancedSettings = self.tunnelStore.advancedSettings(), - let url = URL(string: advancedSettings.authBaseURLString) - { - return url - } - return URL(string: AdvancedSettings.defaultValue.authBaseURLString)! - } - - private func upateLoginStatus() { - Task { - logger.log("\(#function): Tunnel auth status is \(self.tunnelStore.tunnelAuthStatus)") - let tunnelAuthStatus = tunnelStore.tunnelAuthStatus - let loginStatus = await self.getLoginStatus(from: tunnelAuthStatus) - if tunnelAuthStatus != self.tunnelStore.tunnelAuthStatus { - // The tunnel auth status has changed while we were getting the - // login status, so this login status is not to be used. - logger.log("\(#function): Ignoring login status \(loginStatus) that's no longer valid.") - return - } - logger.log("\(#function): Corresponding login status is \(loginStatus)") - await MainActor.run { - self.loginStatus = loginStatus - } - } - } - - private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus { - switch tunnelAuthStatus { - case .uninitialized: - return .uninitialized - case .noTunnelFound: - return .needsTunnelCreationPermission - case .signedOut: - return .signedOut - case .signedIn(let tunnelAuthBaseURL, let tokenReference): - guard self.authBaseURL == tunnelAuthBaseURL else { - return .signedOut - } - let tunnelBaseURLString = self.authBaseURL.absoluteString - guard let tokenAttributes = await keychain.loadAttributes(tokenReference), - tunnelBaseURLString == tokenAttributes.authBaseURLString - else { - return .signedOut - } - return .signedIn(actorName: tokenAttributes.actorName) - } - } - - func signIn() async throws { - logger.log("\(#function)") - - let authResponse = try await auth.signIn(self.authBaseURL) - let attributes = Keychain.TokenAttributes( - authBaseURLString: self.authBaseURL.absoluteString, actorName: authResponse.actorName ?? "") - let tokenRef = try await keychain.store(authResponse.token, attributes) - - try await tunnelStore.saveAuthStatus( - .signedIn(authBaseURL: self.authBaseURL, tokenReference: tokenRef)) - } - - func signOut() async { - logger.log("\(#function)") - - guard case .signedIn = self.tunnelStore.tunnelAuthStatus else { - logger.log("\(#function): Not signed in, so can't signout.") - return - } - - do { - try await tunnelStore.stop() - if let tokenRef = try await tunnelStore.signOut() { - try await keychain.delete(tokenRef) - } - } catch { - logger.error("\(#function): Error signing out: \(error)") - } - } - - public func cancelSignIn() { - auth.cancelSignIn() - } - - func startTunnel() { - logger.log("\(#function)") - - guard case .signedIn = self.tunnelStore.tunnelAuthStatus else { - logger.log("\(#function): Not signed in, so can't start the tunnel.") - return - } - - Task { - do { - try await tunnelStore.start() - } catch { - if case TunnelStoreError.startTunnelErrored(let startTunnelError) = error { - logger.error( - "\(#function): Starting tunnel errored: \(String(describing: startTunnelError))" - ) - handleTunnelDisconnectionEvent() - } else { - logger.error("\(#function): Starting tunnel failed: \(String(describing: error))") - // Disconnection event will be handled in the tunnel status change handler - } - } - } - } - - func handleTunnelDisconnectionEvent() { - logger.log("\(#function)") - if let tsEvent = TunnelShutdownEvent.loadFromDisk(logger: logger) { - self.logger.log( - "\(#function): Tunnel shutdown event: \(tsEvent)" - ) - switch tsEvent.action { - case .signoutImmediately: - Task { - await self.signOut() - } - #if os(macOS) - SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, authStore: self) - #endif - case .signoutImmediatelySilently: - Task { - await self.signOut() - } - case .doNothing: - break - } - } else { - self.logger.log("\(#function): Tunnel shutdown event not found") - } - } - - private func handleLoginStatusChanged() { - logger.log("\(#function): Login status: \(self.loginStatus)") - switch self.loginStatus { - case .signedIn: - self.startTunnel() - case .signedOut: - Task { - do { - try await tunnelStore.stop() - } catch { - logger.error("\(#function): Error stopping tunnel: \(String(describing: error))") - } - if tunnelStore.tunnelAuthStatus != .signedOut { - // Bring tunnelAuthStatus in line, in case it's out of touch with the login status - try await tunnelStore.saveAuthStatus(.signedOut) - } - } - case .needsTunnelCreationPermission: - break - case .uninitialized: - break - } - } - - func tunnelAuthStatus(for authBaseURL: URL) async -> TunnelAuthStatus { - if let tokenRef = await keychain.searchByAuthBaseURL(authBaseURL) { - return .signedIn(authBaseURL: authBaseURL, tokenReference: tokenRef) - } else { - return .signedOut - } - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift index bd90b2bf0..1adf00896 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift @@ -5,33 +5,45 @@ // import Combine +import CryptoKit +import Dependencies import Foundation import NetworkExtension import OSLog +#if os(macOS) + import AppKit +#endif + enum TunnelStoreError: Error { case tunnelCouldNotBeStarted case tunnelCouldNotBeStopped - case cannotSaveToTunnelWhenConnected + case cannotSaveWhenConnected + case cannotSaveIfMissing case cannotSignOutWhenConnected case stopAlreadyBeingAttempted case startTunnelErrored(Error) } -public struct TunnelProviderKeys { - static let keyAuthBaseURLString = "authBaseURLString" - public static let keyConnlibLogFilter = "connlibLogFilter" +public struct TunnelStoreKeys { + static let actorName = "actorName" + static let authBaseURL = "authBaseURL" + static let apiURL = "apiURL" + public static let logFilter = "logFilter" } +/// A utility class for managing our VPN profile in System Preferences public final class TunnelStore: ObservableObject { - @Published private var tunnel: NETunnelProviderManager? - @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .uninitialized + // Make our tunnel configuration convenient for SettingsView to consume + @Published private(set) var settings: Settings - @Published private(set) var status: NEVPNStatus { - didSet { self.logger.log("status changed: \(self.status.description)") } - } + // Enacapsulate Tunnel status here to make it easier for other components + // to observe + @Published private(set) var status: NEVPNStatus - @Published private(set) var resources = DisplayableResources() + @Published private(set) var resourceListJSON: String? + + public var manager: NETunnelProviderManager? private var resourcesTimer: Timer? { didSet(oldValue) { oldValue?.invalidate() } @@ -39,186 +51,211 @@ public final class TunnelStore: ObservableObject { private let logger: AppLogger private var tunnelObservingTasks: [Task] = [] - private var startTunnelContinuation: CheckedContinuation<(), Error>? - private var stopTunnelContinuation: CheckedContinuation<(), Error>? private var cancellables = Set() + // Use separate bundle IDs for release and debug. Helps with testing releases + // and dev builds on the same Mac. + #if DEBUG + private let bundleIdentifier = Bundle.main.bundleIdentifier.map { + "\($0).debug.network-extension" + } + private let bundleDescription = "Firezone (Debug)" + #else + private let bundleIdentifier = Bundle.main.bundleIdentifier.map { "\($0).network-extension" } + private let bundleDescription = "Firezone" + #endif + + @Dependency(\.keychain) private var keychain + @Dependency(\.auth) private var auth + @Dependency(\.mainQueue) private var mainQueue + public init(logger: AppLogger) { - self.tunnel = nil - self.tunnelAuthStatus = .uninitialized - self.status = .invalid self.logger = logger + self.status = .disconnected + self.manager = nil + self.settings = Settings.defaultValue + + // Connect UI state updates to this manager's status + setupTunnelObservers() Task { - await initializeTunnel() - setupTunnelObservers() - } - } + // loadAllFromPreferences() returns list of tunnel configurations we created. Since our bundle ID + // can change (by us), find the one that's current and ignore the others. + let managers = try! await NETunnelProviderManager.loadAllFromPreferences() + logger.log("\(#function): \(managers.count) tunnel managers found") + for manager in managers { + if let protocolConfiguration = (manager.protocolConfiguration as? NETunnelProviderProtocol), + protocolConfiguration.providerBundleIdentifier == bundleIdentifier + { + self.settings = Settings.fromProviderConfiguration( + providerConfiguration: protocolConfiguration.providerConfiguration as? [String: String]) + self.manager = manager + self.status = manager.connection.status - func initializeTunnel() async { - // loadAllFromPreferences() returns list of tunnel configurations we created. Since our bundle ID - // can change (by us), find the one that's current and ignore the others. - let managers = try! await NETunnelProviderManager.loadAllFromPreferences() - logger.log("\(#function): \(managers.count) tunnel managers found") - for manager in managers { - if let protocolConfig = (manager.protocolConfiguration as? NETunnelProviderProtocol), - protocolConfig.providerBundleIdentifier == NETunnelProviderManager.bundleIdentifier() { - self.tunnel = manager - self.tunnelAuthStatus = manager.authStatus() - self.status = manager.connection.status - - // Stop looking for our tunnel - break + // Stop looking for our tunnel + break + } } - self.tunnelAuthStatus = .noTunnelFound + // Connect on app launch unless we're already connected + if let _ = manager?.protocolConfiguration?.passwordReference, + self.status == .disconnected + { + try await start() + } + + // If we haven't found a manager by this point, consider our status invalid + // to prompt creating one. + if manager == nil { + self.status = .invalid + } } } - func createTunnel() async throws { - guard self.tunnel == nil else { - return - } - let tunnel = NETunnelProviderManager() - tunnel.localizedDescription = NETunnelProviderManager.bundleDescription() - tunnel.protocolConfiguration = basicProviderProtocol() - try await tunnel.saveToPreferences() - logger.log("\(#function): Tunnel created") - self.tunnel = tunnel - self.tunnelAuthStatus = tunnel.authStatus() + // If firezone-id hasn't ever been written, the app is considered + // to be launched for the first time. + func firstTime() -> Bool { + let fileExists = FileManager.default.fileExists( + atPath: SharedAccess.baseFolderURL.appendingPathComponent("firezone-id").path + ) + return !fileExists } - func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws { - logger.log("TunnelStore.\(#function) \(tunnelAuthStatus)") - guard let tunnel = tunnel else { - fatalError("No tunnel yet. Can't save auth status.") + // Initialize and save a new VPN profile in system Preferences + func createManager() async throws { + if let manager = manager { + // Someone deleted the manager while Fireone is running! + // Let's assume that was an accident and recreate it from + // our current state. + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + } else { + let protocolConfiguration = NETunnelProviderProtocol() + let manager = NETunnelProviderManager() + let providerConfiguration = + protocolConfiguration.providerConfiguration + as? [String: String] + ?? Settings.defaultValue.toProviderConfiguration() + + protocolConfiguration.providerConfiguration = providerConfiguration + protocolConfiguration.providerBundleIdentifier = bundleIdentifier + protocolConfiguration.serverAddress = providerConfiguration[TunnelStoreKeys.apiURL] + manager.localizedDescription = bundleDescription + manager.protocolConfiguration = protocolConfiguration + + // Save the new VPN profile to System Preferences + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + + self.manager = manager + self.status = .disconnected } - - let tunnelStatus = tunnel.connection.status - if tunnelStatus == .connected || tunnelStatus == .connecting { - throw TunnelStoreError.cannotSaveToTunnelWhenConnected - } - - try await tunnel.loadFromPreferences() - try await tunnel.saveAuthStatus(tunnelAuthStatus) - self.tunnelAuthStatus = tunnelAuthStatus - } - - func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws { - logger.log("TunnelStore.\(#function) \(advancedSettings)") - guard let tunnel = tunnel else { - fatalError("No tunnel yet. Can't save advanced settings.") - } - - let tunnelStatus = tunnel.connection.status - if tunnelStatus == .connected || tunnelStatus == .connecting { - throw TunnelStoreError.cannotSaveToTunnelWhenConnected - } - - try await tunnel.loadFromPreferences() - try await tunnel.saveAdvancedSettings(advancedSettings) - self.tunnelAuthStatus = tunnel.authStatus() - } - - func advancedSettings() -> AdvancedSettings? { - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") - return nil - } - - return tunnel.advancedSettings() - } - - func basicProviderProtocol() -> NETunnelProviderProtocol { - let protocolConfiguration = NETunnelProviderProtocol() - protocolConfiguration.providerBundleIdentifier = NETunnelProviderManager.bundleIdentifier() - protocolConfiguration.serverAddress = AdvancedSettings.defaultValue.apiURLString - protocolConfiguration.providerConfiguration = [ - TunnelProviderKeys.keyConnlibLogFilter: - AdvancedSettings.defaultValue.connlibLogFilterString - ] - return protocolConfiguration } func start() async throws { - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") - return - } - logger.log("\(#function)") - if tunnel.connection.status == .connected || tunnel.connection.status == .connecting { + guard let manager = manager + else { + logger.error("\(#function): No manager created yet") return } - if tunnel.advancedSettings().connlibLogFilterString.isEmpty { - tunnel.setConnlibLogFilter(AdvancedSettings.defaultValue.connlibLogFilterString) + guard ![.connected, .connecting].contains(status) + else { + logger.log("\(#function): Already connected") + return } - tunnel.isEnabled = true - try await tunnel.saveToPreferences() - try await tunnel.loadFromPreferences() + manager.isEnabled = true + try await manager.saveToPreferences() + try await manager.loadFromPreferences() - let session = castToSession(tunnel.connection) + let session = castToSession(manager.connection) do { try session.startTunnel() } catch { throw TunnelStoreError.startTunnelErrored(error) } - try await withCheckedThrowingContinuation { continuation in - self.startTunnelContinuation = continuation - } } func stop() async throws { - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") + logger.log("\(#function)") + + guard let manager = manager else { + logger.error("\(#function): No manager created yet") return } - guard self.stopTunnelContinuation == nil else { - throw TunnelStoreError.stopAlreadyBeingAttempted - } - - logger.log("\(#function)") - - let status = tunnel.connection.status - if status == .connected || status == .connecting { - let session = castToSession(tunnel.connection) - session.stopTunnel() - try await withCheckedThrowingContinuation { continuation in - self.stopTunnelContinuation = continuation - } + guard [.connected, .connecting, .reasserting].contains(status) + else { + logger.error("\(#function): Already stopped") + return } + let session = castToSession(manager.connection) + session.stopTunnel() } - func signOut() async throws -> Keychain.PersistentRef? { - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") - return nil + public func cancelSignIn() { + auth.cancelSignIn() + } + + func signIn() async throws { + logger.log("\(#function)") + guard let manager = manager, + let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] + else { + logger.error("\(#function): Can't sign in if our tunnel configuration is missing!") + return } - let tunnelStatus = tunnel.connection.status - if tunnelStatus == .connected || tunnelStatus == .connecting { - throw TunnelStoreError.cannotSignOutWhenConnected + let authURL = URL(string: settings.authBaseURL)! + let authResponse = try await auth.signIn(authURL) + + // Apple recommends updating Keychain items in place if possible + var tokenRef: Keychain.PersistentRef + if let ref = await keychain.search() { + try await keychain.update(authResponse.token) + tokenRef = ref + } else { + tokenRef = try await keychain.add(authResponse.token) } - if case .signedIn(_, let tokenReference) = self.tunnelAuthStatus { - do { - try await saveAuthStatus(.signedOut) - } catch { - logger.log( - "\(#function): Error saving signed out auth status: \(error)" - ) - } - return tokenReference + // Save token and actorName + providerConfiguration[TunnelStoreKeys.actorName] = authResponse.actorName + protocolConfiguration.providerConfiguration = providerConfiguration + protocolConfiguration.passwordReference = tokenRef + manager.protocolConfiguration = protocolConfiguration + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + + // Start tunnel + try await start() + } + + func signOut() async throws { + logger.log("\(#function)") + guard let manager = manager, + ![.disconnecting, .disconnected].contains(status), + let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol + else { + logger.error("\(#function): Tunnel seems to be already disconnected") + return } - return nil + // Clear token from VPN profile, but keep it in Keychain because the user + // may have customized the Keychain Item. + protocolConfiguration.passwordReference = nil + try await manager.saveToPreferences() + + // Stop tunnel + try await stop() } func beginUpdatingResources() { + logger.log("\(#function)") + self.updateResources() let intervalInSeconds: TimeInterval = 1 let timer = Timer(timeInterval: intervalInSeconds, repeats: true) { [weak self] _ in @@ -234,31 +271,64 @@ public final class TunnelStore: ObservableObject { self.resourcesTimer = nil } + func save(_ settings: Settings) async throws { + logger.log("\(#function)") + + guard let manager = manager, + let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] + else { + logger.error("Manager doesn't seem initialized. Can't save advanced settings.") + throw TunnelStoreError.cannotSaveIfMissing + } + + if [.connected, .connecting].contains(manager.connection.status) { + throw TunnelStoreError.cannotSaveWhenConnected + } + + providerConfiguration = settings.toProviderConfiguration() + protocolConfiguration.providerConfiguration = providerConfiguration + protocolConfiguration.serverAddress = providerConfiguration[TunnelStoreKeys.apiURL] + manager.protocolConfiguration = protocolConfiguration + try await manager.saveToPreferences() + self.settings = settings + self.manager = manager + self.status = manager.connection.status + } + + func actorName() -> String? { + guard let manager = manager, + let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration + else { + logger.error("\(#function): Tunnel not initialized!") + return nil + } + + return providerConfiguration[TunnelStoreKeys.actorName] as? String + } + private func castToSession(_ connection: NEVPNConnection) -> NETunnelProviderSession { guard let session = connection as? NETunnelProviderSession else { fatalError("Could not cast tunnel connection to NETunnelProviderSession!") } + return session } private func updateResources() { - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") + guard let manager = manager + else { + logger.error("\(#function): No tunnel created yet") return } - let session = castToSession(tunnel.connection) - guard session.status == .connected else { - self.resources = DisplayableResources() - return - } - let resourcesQuery = resources.versionStringToData() + let session = castToSession(manager.connection) + let hash = Data(SHA256.hash(data: Data((resourceListJSON ?? "").utf8))) do { - try session.sendProviderMessage(resourcesQuery) { [weak self] reply in - if let reply = reply { // If reply is nil, then the resources have not changed - if let updatedResources = DisplayableResources(from: reply) { - self?.resources = updatedResources - } + try session.sendProviderMessage(hash) { [weak self] reply in + if let reply = reply { + self?.resourceListJSON = String(data: reply, encoding: .utf8) } } } catch { @@ -266,6 +336,8 @@ public final class TunnelStore: ObservableObject { } } + // Receive notifications about our VPN profile status changing, + // and sync our status to it so UI components can react accordingly. private func setupTunnelObservers() { logger.log("\(#function)") @@ -275,80 +347,87 @@ public final class TunnelStore: ObservableObject { tunnelObservingTasks.append( Task { for await notification in NotificationCenter.default.notifications( - named: .NEVPNStatusDidChange, - object: nil + named: .NEVPNStatusDidChange ) { - guard let session = notification.object as? NETunnelProviderSession else { - return - } - let status = session.status - self.status = status - if let startTunnelContinuation = self.startTunnelContinuation { - switch status { - case .connected: - startTunnelContinuation.resume(returning: ()) - self.startTunnelContinuation = nil - case .disconnected: - startTunnelContinuation.resume(throwing: TunnelStoreError.tunnelCouldNotBeStarted) - self.startTunnelContinuation = nil - default: - break - } - } - if let stopTunnelContinuation = self.stopTunnelContinuation { - switch status { - case .disconnected: - stopTunnelContinuation.resume(returning: ()) - self.stopTunnelContinuation = nil - case .connected: - stopTunnelContinuation.resume(throwing: TunnelStoreError.tunnelCouldNotBeStopped) - self.stopTunnelContinuation = nil - default: - break - } - } - if status != .connected { - self.resources = DisplayableResources() + await self.handleVPNStatusChange(notification) + } + for await _ in NotificationCenter.default.notifications( + named: .NEVPNConfigurationChange + ) { + do { + try await self.handleVPNConfigurationChange() + } catch { + logger.error( + "\(#function): Error while trying to handle VPN configuration change: \(error)") } } } ) } - func removeProfile() async throws { - logger.log("\(#function)") - guard let tunnel = tunnel else { - logger.log("\(#function): No tunnel created yet") + private func handleVPNStatusChange(_ notification: Notification) async { + guard let session = notification.object as? NETunnelProviderSession + else { + logger.error("\(#function): NEVPNStatusDidChange notification doesn't seem to be valid") + return + } + logger.log("\(#function): NEVPNStatusDidChange: \(session.status)") + status = session.status + + if case .disconnected = status, + let savedValue = try? String(contentsOf: SharedAccess.providerStopReasonURL, encoding: .utf8), + let rawValue = Int(savedValue), + let reason = NEProviderStopReason(rawValue: rawValue), + case .authenticationCanceled = reason + { + await consumeCanceledAuthentication() + } + + if status != .connected { + // Reset resources list + resourceListJSON = nil + } + } + + // Handle cases where our stored tunnel manager changes out from under us. + // This can happen for example if another VPN app turns on and the system + // decides to turn ours off. + private func handleVPNConfigurationChange() async throws { + guard let manager = manager + else { + logger.error("\(#function): Our manager is somehow gone!") return } - try await tunnel.removeFromPreferences() - } -} + try await manager.loadFromPreferences() -enum TunnelAuthStatus: Equatable, CustomStringConvertible { - case uninitialized - case noTunnelFound - case signedOut - case signedIn(authBaseURL: URL, tokenReference: Data) + if !manager.isEnabled { + logger.log("\(#function): Something turned us off! Shutting down the tunnel") - var isInitialized: Bool { - switch self { - case .uninitialized: return false - default: return true + try await stop() } } - var description: String { - switch self { - case .uninitialized: - return "tunnel uninitialized" - case .noTunnelFound: - return "no tunnel found" - case .signedOut: - return "signedOut" - case .signedIn(let authBaseURL, _): - return "signedIn(authBaseURL: \(authBaseURL))" + private func consumeCanceledAuthentication() async { + try? FileManager.default.removeItem(at: SharedAccess.providerStopReasonURL) + + // Show alert (macOS -- iOS is handled in the PacketTunnelProvider) + // TODO: See if we can show standard notifications NotificationCenter here + #if os(macOS) + DispatchQueue.main.async { + SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, tunnelStore: self) + } + #endif + + // Clear tokenRef + guard let manager = manager else { return } + let protocolConfiguration = manager.protocolConfiguration + protocolConfiguration?.passwordReference = nil + manager.protocolConfiguration = protocolConfiguration + do { + try await manager.saveToPreferences() + } catch { + logger.error("\(#function): Couldn't clear tokenRef") } } } @@ -369,145 +448,3 @@ extension NEVPNStatus: CustomStringConvertible { } } } - -extension NETunnelProviderManager { - func authStatus() -> TunnelAuthStatus { - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol, - let providerConfig = protocolConfiguration.providerConfiguration - { - let authBaseURL: URL? = { - guard let urlString = providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String - else { - return nil - } - return URL(string: urlString) - }() - let tokenRef = protocolConfiguration.passwordReference - if let authBaseURL = authBaseURL { - if let tokenRef = tokenRef { - return .signedIn(authBaseURL: authBaseURL, tokenReference: tokenRef) - } else { - return .signedOut - } - } else { - return .signedOut - } - } - return .signedOut - } - - func saveAuthStatus(_ authStatus: TunnelAuthStatus) async throws { - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol { - var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:] - - switch authStatus { - case .uninitialized, .noTunnelFound: - return - - case .signedOut: - protocolConfiguration.passwordReference = nil - break - case .signedIn(let authBaseURL, let tokenReference): - providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString - protocolConfiguration.passwordReference = tokenReference - } - - protocolConfiguration.providerConfiguration = providerConfig - - ensureTunnelConfigurationIsValid() - try await saveToPreferences() - } - } - - func advancedSettings() -> AdvancedSettings { - let defaultValue = AdvancedSettings.defaultValue - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol { - let apiURLString = protocolConfiguration.serverAddress ?? defaultValue.apiURLString - var authBaseURLString = defaultValue.authBaseURLString - var logFilter = defaultValue.connlibLogFilterString - if let providerConfig = protocolConfiguration.providerConfiguration { - if let authBaseURLStringInProviderConfig = - (providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String) - { - authBaseURLString = authBaseURLStringInProviderConfig - } - if let logFilterInProviderConfig = - (providerConfig[TunnelProviderKeys.keyConnlibLogFilter] as? String) - { - logFilter = logFilterInProviderConfig - } - } - - return AdvancedSettings( - authBaseURLString: authBaseURLString, - apiURLString: apiURLString, - connlibLogFilterString: logFilter - ) - } - - return defaultValue - } - - func setConnlibLogFilter(_ logFiler: String) { - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration - { - var providerConfig = providerConfiguration - providerConfig[TunnelProviderKeys.keyConnlibLogFilter] = logFiler - protocolConfiguration.providerConfiguration = providerConfig - } - } - - func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws { - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol { - var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:] - - providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = - advancedSettings.authBaseURLString - providerConfig[TunnelProviderKeys.keyConnlibLogFilter] = - advancedSettings.connlibLogFilterString - - protocolConfiguration.providerConfiguration = providerConfig - protocolConfiguration.serverAddress = advancedSettings.apiURLString - - ensureTunnelConfigurationIsValid() - try await saveToPreferences() - } - } - - private func ensureTunnelConfigurationIsValid() { - // Ensure the tunnel config has required values populated, because - // to even sign out, we need saveToPreferences() to succeed. - if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol { - protocolConfiguration.providerBundleIdentifier = - Self.bundleIdentifier() - if protocolConfiguration.serverAddress?.isEmpty ?? true { - protocolConfiguration.serverAddress = "unknown-server" - } - } else { - let protocolConfiguration = NETunnelProviderProtocol() - protocolConfiguration.providerBundleIdentifier = - Self.bundleIdentifier() - protocolConfiguration.serverAddress = "unknown-server" - } - if localizedDescription?.isEmpty ?? true { - localizedDescription = Self.bundleDescription() - } - } - - static func bundleIdentifier() -> String? { - #if DEBUG - Bundle.main.bundleIdentifier.map { "\($0).debug.network-extension" } - #else - Bundle.main.bundleIdentifier.map { "\($0).network-extension" } - #endif - } - - static func bundleDescription() -> String { - #if DEBUG - "Firezone (Debug)" - #else - "Firezone" - #endif - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 4d8388759..0747ab522 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -17,9 +17,10 @@ private var cancellables: Set = [] private var statusItem: NSStatusItem - private var orderedResources: [DisplayableResources.Resource] = [] + + private var resources: [Resource]? private var isMenuVisible = false { - didSet { handleMenuVisibilityOrStatusChanged() } + didSet { handleMenuVisibilityOrStatusChanged(status: tunnelStore.status) } } private lazy var signedOutIcon = NSImage(named: "MenuBarIconSignedOut") private lazy var signedInConnectedIcon = NSImage(named: "MenuBarIconSignedInConnected") @@ -32,60 +33,62 @@ private var connectingAnimationImageIndex: Int = 0 private var connectingAnimationTimer: Timer? - private var appStore: AppStore + private var tunnelStore: TunnelStore private var settingsViewModel: SettingsViewModel - private var loginStatus: AuthStore.LoginStatus = .signedOut - private var tunnelStatus: NEVPNStatus = .invalid private var logger: AppLogger - public init(appStore: AppStore) { + public init(tunnelStore: TunnelStore, settingsViewModel: SettingsViewModel, logger: AppLogger) { self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - self.appStore = appStore - self.settingsViewModel = appStore.settingsViewModel - - self.logger = appStore.logger - + self.tunnelStore = tunnelStore + self.settingsViewModel = settingsViewModel + self.logger = logger super.init() - createMenu() - setupObservers() if let button = statusItem.button { button.image = signedOutIcon } - updateStatusItemIcon() + createMenu() + setupObservers() } private func setupObservers() { - appStore.authStore.$loginStatus - .receive(on: mainQueue) - .sink { [weak self] loginStatus in - self?.loginStatus = loginStatus - self?.updateStatusItemIcon() - self?.handleLoginOrTunnelStatusChanged() - } - .store(in: &cancellables) - - appStore.tunnelStore.$status + tunnelStore.$status .receive(on: mainQueue) .sink { [weak self] status in - self?.tunnelStatus = status - self?.updateStatusItemIcon() - self?.handleLoginOrTunnelStatusChanged() - self?.handleMenuVisibilityOrStatusChanged() + guard let self = self else { return } + self.updateStatusItemIcon(status: status) + self.handleTunnelStatusChanged(status: status) + self.handleMenuVisibilityOrStatusChanged(status: status) } .store(in: &cancellables) - appStore.tunnelStore.$resources + tunnelStore.$resourceListJSON .receive(on: mainQueue) - .sink { [weak self] resources in + .sink { [weak self] json in guard let self = self else { return } - self.setOrderedResources(resources.orderedResources) + + let newResources = decodeResources(json) + + // Update menu in-place for smooth, silky, easy-on-the-eyes UI updates + populateResourceMenu(newResources) + resourcesTitleMenuItem.title = resourceMenuTitle(newResources) + + // Save what we got so we know when it changes next + resources = newResources } .store(in: &cancellables) } + private func decodeResources(_ json: String?) -> [Resource]? { + guard let json = json, + let data = json.data(using: .utf8) + else { return nil } + + return try? JSONDecoder().decode([Resource].self, from: data) + } + + // FIXME: Use SwiftUI for the menubar private lazy var menu = NSMenu() private lazy var signInMenuItem = createMenuItem( @@ -103,7 +106,7 @@ ) private lazy var resourcesTitleMenuItem = createMenuItem( menu, - title: "No Resources", + title: "Loading Resources...", action: nil, isHidden: true, target: self @@ -191,16 +194,10 @@ return item } - @objc private func reconnectButtonTapped() { - if case .signedIn = appStore.authStore.loginStatus { - appStore.authStore.startTunnel() - } - } - @objc private func signInButtonTapped() { Task { do { - try await appStore.authStore.signIn() + try await tunnelStore.signIn() } catch { logger.error("Error signing in: \(String(describing: error))") } @@ -209,7 +206,7 @@ @objc private func signOutButtonTapped() { Task { - await appStore.authStore.signOut() + try await tunnelStore.signOut() } } @@ -225,7 +222,7 @@ @objc private func quitButtonTapped() { Task { do { - try await appStore.tunnelStore.stop() + try await tunnelStore.stop() } catch { logger.error("\(#function): Error stopping tunnel: \(error)") } @@ -233,32 +230,27 @@ } } - private func updateStatusItemIcon() { + private func updateStatusItemIcon(status: NEVPNStatus) { self.statusItem.button?.image = { - switch self.loginStatus { - case .signedOut, .uninitialized, .needsTunnelCreationPermission: + switch status { + case .invalid, .disconnected: + self.stopConnectingAnimation() return self.signedOutIcon - case .signedIn: - switch self.tunnelStatus { - case .connected: - self.stopConnectingAnimation() - return self.signedInConnectedIcon - case .connecting, .disconnecting, .reasserting: - self.startConnectingAnimation() - return self.connectingAnimationImages.last! - case .invalid, .disconnected: - self.stopConnectingAnimation() - return self.signedOutIcon - @unknown default: - return nil - } + case .connected: + self.stopConnectingAnimation() + return self.signedInConnectedIcon + case .connecting, .disconnecting, .reasserting: + self.startConnectingAnimation() + return self.connectingAnimationImages.last! + @unknown default: + return nil } }() } private func startConnectingAnimation() { guard connectingAnimationTimer == nil else { return } - let timer = Timer(timeInterval: 0.40, repeats: true) { [weak self] _ in + let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] _ in guard let self = self else { return } Task { await self.connectingAnimationShowNextFrame() @@ -269,100 +261,94 @@ } private func stopConnectingAnimation() { - guard let timer = self.connectingAnimationTimer else { return } - timer.invalidate() - connectingAnimationTimer = nil - connectingAnimationImageIndex = 0 + connectingAnimationTimer?.invalidate() + self.connectingAnimationTimer = nil } - private func connectingAnimationShowNextFrame() async { + private func connectingAnimationShowNextFrame() { self.statusItem.button?.image = self.connectingAnimationImages[self.connectingAnimationImageIndex] self.connectingAnimationImageIndex = (self.connectingAnimationImageIndex + 1) % self.connectingAnimationImages.count } - private func handleLoginOrTunnelStatusChanged() { + private func handleTunnelStatusChanged(status: NEVPNStatus) { // Update "Sign In" / "Sign Out" menu items - switch self.loginStatus { - case .uninitialized: - signInMenuItem.title = "Initializing" - signInMenuItem.target = nil - signOutMenuItem.isHidden = true - settingsMenuItem.target = nil - case .needsTunnelCreationPermission: + switch status { + case .invalid: signInMenuItem.title = "Requires VPN permission" signInMenuItem.target = nil signOutMenuItem.isHidden = true settingsMenuItem.target = nil - case .signedOut: + case .disconnected: signInMenuItem.title = "Sign In" signInMenuItem.target = self signInMenuItem.isEnabled = true signOutMenuItem.isHidden = true settingsMenuItem.target = self - case .signedIn(let actorName): - signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)" + case .disconnecting: + signInMenuItem.title = "Signing out..." + signInMenuItem.target = self + signInMenuItem.isEnabled = false + signOutMenuItem.isHidden = true + settingsMenuItem.target = self + case .connected, .reasserting, .connecting: + let title = + if let actorName = tunnelStore.actorName() { + "Signed in as \(actorName)" + } else { + "Signed in" + } + signInMenuItem.title = title signInMenuItem.target = nil signOutMenuItem.isHidden = false settingsMenuItem.target = self + @unknown default: + break } // Update resources "header" menu items - switch (self.loginStatus, self.tunnelStatus) { - case (.uninitialized, _): - resourcesTitleMenuItem.isHidden = true - resourcesUnavailableMenuItem.isHidden = true - resourcesUnavailableReasonMenuItem.isHidden = true - resourcesSeparatorMenuItem.isHidden = true - case (.needsTunnelCreationPermission, _): - resourcesTitleMenuItem.isHidden = true - resourcesUnavailableMenuItem.isHidden = true - resourcesUnavailableReasonMenuItem.isHidden = true - resourcesSeparatorMenuItem.isHidden = true - case (.signedOut, _): - resourcesTitleMenuItem.isHidden = true - resourcesUnavailableMenuItem.isHidden = true - resourcesUnavailableReasonMenuItem.isHidden = true - resourcesSeparatorMenuItem.isHidden = true - case (.signedIn, .connecting): + switch tunnelStore.status { + case .connecting: resourcesTitleMenuItem.isHidden = true resourcesUnavailableMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.target = nil resourcesUnavailableReasonMenuItem.title = "Connecting…" resourcesSeparatorMenuItem.isHidden = false - case (.signedIn, .connected): + case .connected: resourcesTitleMenuItem.isHidden = false resourcesUnavailableMenuItem.isHidden = true resourcesUnavailableReasonMenuItem.isHidden = true - resourcesTitleMenuItem.title = "Resources" + resourcesTitleMenuItem.title = resourceMenuTitle(resources) resourcesSeparatorMenuItem.isHidden = false - case (.signedIn, .reasserting): + case .reasserting: resourcesTitleMenuItem.isHidden = true resourcesUnavailableMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.target = nil resourcesUnavailableReasonMenuItem.title = "No network connectivity" resourcesSeparatorMenuItem.isHidden = false - case (.signedIn, .disconnecting): + case .disconnecting: resourcesTitleMenuItem.isHidden = true resourcesUnavailableMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.isHidden = false resourcesUnavailableReasonMenuItem.target = nil resourcesUnavailableReasonMenuItem.title = "Disconnecting…" resourcesSeparatorMenuItem.isHidden = false - case (.signedIn, .disconnected), (.signedIn, .invalid), (.signedIn, _): + case .disconnected, .invalid: // We should never be in a state where the tunnel is // down but the user is signed in, but we have // code to handle it just for the sake of completion. resourcesTitleMenuItem.isHidden = true - resourcesUnavailableMenuItem.isHidden = false - resourcesUnavailableReasonMenuItem.isHidden = false + resourcesUnavailableMenuItem.isHidden = true + resourcesUnavailableReasonMenuItem.isHidden = true resourcesUnavailableReasonMenuItem.title = "Disconnected" - resourcesSeparatorMenuItem.isHidden = false + resourcesSeparatorMenuItem.isHidden = true + @unknown default: + break } quitMenuItem.title = { - switch self.tunnelStatus { + switch status { case .connected, .connecting: return "Disconnect and Quit" default: @@ -371,38 +357,40 @@ }() } - private func handleMenuVisibilityOrStatusChanged() { - let status = appStore.tunnelStore.status - if isMenuVisible && status == .connected { - appStore.tunnelStore.beginUpdatingResources() + private func resourceMenuTitle(_ newResources: [Resource]?) -> String { + guard let newResources = newResources else { return "Loading Resources..." } + + if newResources.isEmpty { + return "No Resources" } else { - appStore.tunnelStore.endUpdatingResources() + return "Resources" } } - private func setOrderedResources(_ newOrderedResources: [DisplayableResources.Resource]) { - if resourcesTitleMenuItem.isHidden && resourcesSeparatorMenuItem.isHidden { - guard newOrderedResources.isEmpty else { - return - } + private func handleMenuVisibilityOrStatusChanged(status: NEVPNStatus) { + if isMenuVisible && status == .connected { + tunnelStore.beginUpdatingResources() + } else { + tunnelStore.endUpdatingResources() } - let diff = newOrderedResources.difference( - from: self.orderedResources, - by: { $0.name == $1.name && $0.location == $1.location } + } + + private func populateResourceMenu(_ newResources: [Resource]?) { + // the menu contains other things besides resources, so update it in-place + let diff = (newResources ?? []).difference( + from: resources ?? [], + by: { $0.name == $1.name && $0.address == $1.address } ) - let baseIndex = menu.index(of: resourcesTitleMenuItem) + 1 + let index = menu.index(of: resourcesTitleMenuItem) + 1 for change in diff { switch change { case .insert(let offset, let element, associatedWith: _): - let menuItem = createResourceMenuItem(title: element.name, submenuTitle: element.location) - menu.insertItem(menuItem, at: baseIndex + offset) - orderedResources.insert(element, at: offset) + let menuItem = createResourceMenuItem(title: element.name, submenuTitle: element.address) + menu.insertItem(menuItem, at: index + offset) case .remove(let offset, element: _, associatedWith: _): - menu.removeItem(at: baseIndex + offset) - orderedResources.remove(at: offset) + menu.removeItem(at: index + offset) } } - resourcesTitleMenuItem.title = orderedResources.isEmpty ? "No Resources" : "Resources" } private func createResourceMenuItem(title: String, submenuTitle: String) -> NSMenuItem { diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 10fbf648c..3899b8856 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -1,3 +1,4 @@ +import CryptoKit // Adapter.swift // (c) 2024 Firezone, Inc. // LICENSE: Apache-2.0 @@ -7,48 +8,23 @@ import Foundation import NetworkExtension import OSLog -#if os(iOS) - import UIKit.UIDevice -#endif - public enum AdapterError: Error { /// Failure to perform an operation in such state. case invalidState /// connlib failed to start case connlibConnectError(Error) - - /// connlib fatal error - case connlibFatalError(String) - - /// No network settings were provided - case noNetworkSettings - - /// Failure to set network settings. - case setNetworkSettings(Error) - - /// stop() called before the tunnel became ready - case stoppedByRequestWhileStarting } /// Enum representing internal state of the adapter private enum AdapterState: CustomStringConvertible { - case startingTunnel(session: WrappedSession, onStarted: Adapter.StartTunnelCompletionHandler?) - case tunnelReady(session: WrappedSession) - case stoppingTunnel(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?) - case stoppedTunnel - case stoppingTunnelTemporarily( - session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?) - case stoppedTunnelTemporarily + case tunnelStarted(session: WrappedSession) + case tunnelStopped var description: String { switch self { - case .startingTunnel: return "startingTunnel" - case .tunnelReady: return "tunnelReady" - case .stoppingTunnel: return "stoppingTunnel" - case .stoppedTunnel: return "stoppedTunnel" - case .stoppingTunnelTemporarily: return "stoppingTunnelTemporarily" - case .stoppedTunnelTemporarily: return "stoppedTunnelTemporarily" + case .tunnelStarted: return "tunnelStarted" + case .tunnelStopped: return "tunnelStopped" } } } @@ -57,7 +33,6 @@ private enum AdapterState: CustomStringConvertible { class Adapter { typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void) - typealias StopTunnelCompletionHandler = (() -> Void) private let logger: AppLogger @@ -72,7 +47,19 @@ class Adapter { /// Network routes monitor. private var networkMonitor: NWPathMonitor? - /// Private queue used to synchronize access to `WireGuardAdapter` members. + /// Used to avoid path update callback cycles on iOS + #if os(iOS) + private var gateways: [Network.NWEndpoint] = [] + #endif + + /// Track our last fetched DNS resolvers to know whether to tell connlib they've updated + private var lastFetchedResolvers: [String] = [] + + /// Used to avoid needlessly sending reconnects to connlib + private var primaryInterfaceName: String? + + /// Private queue used to ensure consistent ordering among path update and connlib callbacks + /// This is the primary async primitive used in this class. private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue") /// Adapter state. @@ -83,227 +70,145 @@ class Adapter { } /// Keep track of resources - private var displayableResources = DisplayableResources() + private var resourceListJSON: String? /// Starting parameters - private var controlPlaneURLString: String + private var apiURL: String private var token: String - private let logFilter: String private let connlibLogFolderPath: String - private let firezoneIdFileURL: URL init( - controlPlaneURLString: String, token: String, - logFilter: String, packetTunnelProvider: PacketTunnelProvider + apiURL: String, + token: String, + logFilter: String, + packetTunnelProvider: PacketTunnelProvider ) { - self.controlPlaneURLString = controlPlaneURLString + self.apiURL = apiURL self.token = token self.packetTunnelProvider = packetTunnelProvider self.callbackHandler = CallbackHandler(logger: packetTunnelProvider.logger) - self.state = .stoppedTunnel + self.state = .tunnelStopped self.logFilter = logFilter self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" - self.firezoneIdFileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id") self.logger = packetTunnelProvider.logger + self.networkSettings = nil } + // Could happen abruptly if the process is killed. deinit { - self.logger.log("Adapter.deinit") + logger.log("Adapter.deinit") + // Cancel network monitor networkMonitor?.cancel() // Shutdown the tunnel - if case .tunnelReady(let session) = self.state { + if case .tunnelStarted(let session) = self.state { logger.log("Adapter.deinit: Shutting down connlib") session.disconnect() } } - /// Start the tunnel tunnel. + /// Start the tunnel. /// - Parameters: /// - completionHandler: completion handler. public func start(completionHandler: @escaping (AdapterError?) -> Void) throws { - workQueue.async { [weak self] in - guard let self = self else { return } + logger.log("Adapter.start") + guard case .tunnelStopped = self.state else { + logger.error("\(#function): Invalid Adapter state") + completionHandler(.invalidState) + return + } - self.logger.log("Adapter.start") - guard case .stoppedTunnel = self.state else { - logger.error("\(#function): Invalid Adapter state") - completionHandler(.invalidState) - return - } + callbackHandler.delegate = self - self.callbackHandler.delegate = self + if connlibLogFolderPath.isEmpty { + logger.error("Cannot get shared log folder for connlib") + } - if self.connlibLogFolderPath.isEmpty { - self.logger.error("Cannot get shared log folder for connlib") - } - - self.logger.log("Adapter.start: Starting connlib") - do { - // We can only get the system's default resolvers before connlib starts, and then they'll - // be overwritten by the ones from connlib. So cache them here for getSystemDefaultResolvers - // to retrieve them later. - self.callbackHandler.setSystemDefaultResolvers( - resolvers: Resolv().getservers().map(Resolv.getnameinfo) + self.logger.log("Adapter.start: Starting connlib") + do { + // Grab a session pointer + let session = + try WrappedSession.connect( + apiURL, + token, + DeviceMetadata.getOrCreateFirezoneId(logger: self.logger), + DeviceMetadata.getDeviceName(), + DeviceMetadata.getOSVersion(), + connlibLogFolderPath, + logFilter, + callbackHandler ) - self.state = .startingTunnel( - session: try WrappedSession.connect( - self.controlPlaneURLString, - self.token, - self.getOrCreateFirezoneId(from: self.firezoneIdFileURL), - self.getDeviceName(), - self.getOSVersion(), - self.connlibLogFolderPath, - self.logFilter, - self.callbackHandler - ), - onStarted: completionHandler - ) - } catch let error { - self.logger.error("Adapter.start: Error: \(error)") - packetTunnelProvider?.handleTunnelShutdown( - dueTo: .connlibConnectFailure, - errorMessage: error.localizedDescription) - self.state = .stoppedTunnel - completionHandler(AdapterError.connlibConnectError(error)) + // Attempt to set DNS right away + if let jsonResolvers = try? String( + decoding: JSONEncoder().encode(getSystemDefaultResolvers()), as: UTF8.self + ).intoRustString() { + session.setDns(jsonResolvers) } + // Update our internal state + self.state = .tunnelStarted(session: session) + + // Start listening for network change events. The first few will be our + // tunnel interface coming up, but that's ok -- it will trigger a `set_dns` + // connlib. + beginPathMonitoring() + + // Tell the system the tunnel is up, moving the tunnelManager status to + // `connected`. + completionHandler(nil) + } catch let error { + logger.error("\(#function): Adapter.start: Error: \(error)") + state = .tunnelStopped + completionHandler(AdapterError.connlibConnectError(error)) } } - /// Stop the tunnel - public func stop(reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - workQueue.async { [weak self] in - guard let self = self else { return } + /// Final callback called by packetTunnelProvider when tunnel is to be stopped. + /// Can happen due to: + /// - User toggles VPN off in Settings.app + /// - User signs out + /// - User clicks "Disconnect and Quit" (macOS) + /// - connlib sends onDisconnect + /// + /// 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() { + logger.log("Adapter.stop") - self.logger.log("Adapter.stop") + if case .tunnelStarted(let session) = state { + state = .tunnelStopped - packetTunnelProvider?.handleTunnelShutdown( - dueTo: .stopped(reason), - errorMessage: "\(reason)") - - switch self.state { - case .stoppedTunnel, .stoppingTunnel: - break - case .tunnelReady(let session): - self.logger.log("Adapter.stop: Shutting down connlib") - session.disconnect() - self.state = .stoppedTunnel - completionHandler() - case .startingTunnel(let session, let onStarted): - self.logger.log("Adapter.stop: Shutting down connlib before tunnel ready") - session.disconnect() - self.state = .stoppedTunnel - onStarted?(AdapterError.stoppedByRequestWhileStarting) - completionHandler() - case .stoppingTunnelTemporarily(let session, let onStopped): - self.state = .stoppedTunnel - onStopped?() - completionHandler() - case .stoppedTunnelTemporarily: - self.state = .stoppedTunnel - completionHandler() - } - - self.networkMonitor?.cancel() - self.networkMonitor = nil + // user-initiated, tell connlib to shut down + session.disconnect() } + + networkMonitor?.cancel() + networkMonitor = nil } - /// Get the current set of resources in the completionHandler. - /// If unchanged since referenceVersionString, call completionHandler(nil). - public func getDisplayableResourcesIfVersionDifferentFrom( - referenceVersionString: String, completionHandler: @escaping (DisplayableResources?) -> Void + /// Get the current set of resources in the completionHandler, only returning + /// them if the resource list has changed. + public func getResourcesIfVersionDifferentFrom( + hash: Data, completionHandler: @escaping (String?) -> Void ) { + // This is async to avoid blocking the main UI thread workQueue.async { [weak self] in guard let self = self else { return } - if referenceVersionString == self.displayableResources.versionString { + if hash == Data(SHA256.hash(data: Data((resourceListJSON ?? "").utf8))) { + // nothing changed completionHandler(nil) } else { - completionHandler(self.displayableResources) + completionHandler(resourceListJSON) } } } } -// MARK: Device metadata - -extension Adapter { - func getDeviceName() -> String? { - // Returns a generic device name on iOS 16 and higher - // See https://github.com/firezone/firezone/issues/3034 - #if os(iOS) - return UIDevice.current.name - #else - // Fallback to connlib's gethostname() - return nil - #endif - } - - func getOSVersion() -> String? { - // Returns the OS version - // See https://github.com/firezone/firezone/issues/3034 - #if os(iOS) - return UIDevice.current.systemVersion - #else - // Fallback to connlib's osinfo - return nil - #endif - } - - // Returns the Firezone ID as cached by the application or generates and persists a new one - // if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device - // for upsert and identification in the admin portal. - func getOrCreateFirezoneId(from fileURL: URL) -> String { - do { - let storedID = try String(contentsOf: fileURL, encoding: .utf8) - self.logger.log("Adapter.getOrCreateFirezoneId: Returning ID from disk: \(storedID)") - return storedID - } catch { - self.logger.log( - "Adapter.getOrCreateFirezoneId: Could not read firezone-id file \(fileURL.path): \(error)" - ) - // Handle the error if the file doesn't exist or isn't readable - // Recreate the file, save a new UUIDv4, and return it - let newUUIDString = UUID().uuidString - - do { - try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8) - self.logger.log("Adapter.getOrCreateFirezoneId: Written ID to disk: \(newUUIDString)") - } catch { - self.logger.error( - "Adapter.getOrCreateFirezoneId: Could not save firezone-id file \(fileURL.path)! Error: \(error)" - ) - } - - return newUUIDString - } - } -} - // MARK: Responding to path updates extension Adapter { - private func resetToSystemDNS() { - // Setting this to anything but an empty string will populate /etc/resolv.conf with - // the default interface's DNS servers, which we read later from connlib - // during tunnel setup. - self.networkSettings?.setMatchDomains(["firezone-fd0020211111"]) - self.networkSettings?.apply( - on: self.packetTunnelProvider, - logger: self.logger, - completionHandler: { _ in - // We can only get the system's default resolvers before connlib starts, and then they'll - // be overwritten by the ones from connlib. So cache them here for getSystemDefaultResolvers - // to retrieve them later. - self.callbackHandler.setSystemDefaultResolvers( - resolvers: Resolv().getservers().map(Resolv.getnameinfo) - ) - }) - } - private func beginPathMonitoring() { self.logger.log("Beginning path monitoring") let networkMonitor = NWPathMonitor() @@ -313,71 +218,110 @@ extension Adapter { networkMonitor.start(queue: self.workQueue) } + /// Primary callback we receive whenever: + /// - Network connectivity changes + /// - System DNS servers change, including when we set them + /// - Routes change, including when we set them + /// + /// Apple doesn't give us very much info in this callback, so we don't know which of the + /// events above triggered the callback. + /// + /// On iOS this creates a problem: + /// + /// We have no good way to get the System's default resolvers. We use a workaround which + /// involves reading the resolvers from Bind (i.e. /etc/resolv.conf) but this will be set to connlib's + /// DNS sentinel while the tunnel is active, which isn't helpful to us. To get around this, we can + /// very briefly update the Tunnel's matchDomains config to *not* be the catch-all [""], which + /// causes iOS to write the actual system resolvers into /etc/resolv.conf, which we can then read. + /// The issue is that this in itself causes a didReceivePathUpdate callback, which makes it hard to + /// differentiate between us changing the DNS configuration and the system actually receiving new + /// default resolvers. + /// + /// So we solve this problem by only doing this DNS dance if the gateways available to the path have + /// changed. This means we only call setDns when the physical network has changed, and therefore + /// we're blind to path updates where only the DNS resolvers have changed. That will happen in two + /// cases most commonly: + /// - New DNS servers were set by DHCP + /// - The user manually changed the DNS servers in the system settings + /// + /// For now, this will break DNS if the old servers connlib is using are no longer valid, and + /// can only be fixed with a sign out and sign back in which restarts the NetworkExtension. + /// + /// On macOS, Apple has exposed the SystemConfiguration framework which makes this easy and + /// doesn't suffer from this issue. + /// + /// See the following issues for discussion around the above issue: + /// - https://github.com/firezone/firezone/issues/3302 + /// - https://github.com/firezone/firezone/issues/3343 + /// - https://github.com/firezone/firezone/issues/3235 + /// - https://github.com/firezone/firezone/issues/3175 private func didReceivePathUpdate(path: Network.NWPath) { - // Will be invoked in the workQueue by the path monitor - switch self.state { + // Ignore path updates if we're not started. Prevents responding to path updates + // we may receive when shutting down. + guard case .tunnelStarted(let session) = state else { return } - case .startingTunnel(let session, let onStarted): - if path.status != .satisfied { - self.logger.log("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.") - onStarted?(nil) - resetToSystemDNS() - self.packetTunnelProvider?.reasserting = true - self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil) - session.disconnect() + if path.status == .unsatisfied { + logger.log("\(#function): Detected network change: Offline.") + + // Check if we need to set reasserting, avoids OS log spam and potentially other side effects + if packetTunnelProvider?.reasserting == false { + // Tell the UI we're not connected + packetTunnelProvider?.reasserting = true + } + } else { + self.logger.log("\(#function): Detected network change: Online.") + + // Hint to connlib we're back online, but only do so if our primary interface changes, + // and therefore we need to bump sockets. On darwin, this is needed to send packets + // out of a different interface even when 0.0.0.0 is used as the source. + // If our primary interface changes, we can be certain the old socket shouldn't be + // used anymore. + if path.availableInterfaces.first?.name != primaryInterfaceName { + session.reconnect() + primaryInterfaceName = path.availableInterfaces.first?.name } - case .tunnelReady(let session): - if path.status != .satisfied { - self.logger.log("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.") - resetToSystemDNS() - self.packetTunnelProvider?.reasserting = true - self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil) - session.disconnect() - } + if shouldFetchSystemResolvers(path: path) { + // Spawn a new thread to avoid blocking the UI on iOS + Task { + let resolvers = getSystemDefaultResolvers( + interfaceName: path.availableInterfaces.first?.name) - case .stoppingTunnelTemporarily: - break + if lastFetchedResolvers != resolvers, + let jsonResolvers = try? String( + decoding: JSONEncoder().encode(resolvers), as: UTF8.self + ).intoRustString() + { - case .stoppedTunnelTemporarily: - guard path.status == .satisfied else { return } + // Update connlib DNS + session.setDns(jsonResolvers) - self.logger.log("Adapter.didReceivePathUpdate: Back online. Starting connlib.") - - do { - self.state = .startingTunnel( - session: try WrappedSession.connect( - controlPlaneURLString, - token, - self.getOrCreateFirezoneId(from: self.firezoneIdFileURL), - self.getDeviceName(), - self.getOSVersion(), - self.connlibLogFolderPath, - self.logFilter, - self.callbackHandler - ), - onStarted: { error in - if let error = error { - self.logger.error( - "Adapter.didReceivePathUpdate: Error starting connlib: \(error)") - self.packetTunnelProvider?.cancelTunnelWithError(error) - } else { - self.packetTunnelProvider?.reasserting = false - } + // Update our state tracker + lastFetchedResolvers = resolvers } - ) - } catch let error as AdapterError { - self.logger.error("Adapter.didReceivePathUpdate: Error: \(error)") - } catch { - self.logger.error( - "Adapter.didReceivePathUpdate: Unknown error: \(error) (fatal)") + } } - case .stoppingTunnel, .stoppedTunnel: - // no-op - break + if packetTunnelProvider?.reasserting == true { + packetTunnelProvider?.reasserting = false + } } } + + #if os(iOS) + private func shouldFetchSystemResolvers(path: Network.NWPath) -> Bool { + if path.gateways != gateways { + gateways = path.gateways + return true + } + + return false + } + #else + private func shouldFetchSystemResolvers(path _: Network.NWPath) -> Bool { + return true + } + #endif } // MARK: Implementing CallbackHandlerDelegate @@ -386,142 +330,143 @@ extension Adapter: CallbackHandlerDelegate { public func onSetInterfaceConfig( tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddresses: [String] ) { + // This is a queued callback to ensure ordering workQueue.async { [weak self] in guard let self = self else { return } - - self.logger.log("Adapter.onSetInterfaceConfig") - - switch self.state { - case .startingTunnel: - self.networkSettings = NetworkSettings( - tunnelAddressIPv4: tunnelAddressIPv4, tunnelAddressIPv6: tunnelAddressIPv6, - dnsAddresses: dnsAddresses) - case .tunnelReady: - if let networkSettings = self.networkSettings { - networkSettings.apply( - on: packetTunnelProvider, - logger: self.logger, - completionHandler: nil - ) - } - - case .stoppingTunnel, .stoppedTunnel, .stoppingTunnelTemporarily, .stoppedTunnelTemporarily: - // This is not expected to happen - break - } - } - } - - public func onTunnelReady() { - workQueue.async { [weak self] in - guard let self = self else { return } - - self.logger.log("Adapter.onTunnelReady") - guard case .startingTunnel(let session, let onStarted) = self.state else { - self.logger.error( - "Adapter.onTunnelReady: Unexpected state: \(self.state)") - return - } - guard let networkSettings = self.networkSettings else { - self.logger.error("Adapter.onTunnelReady: No network settings") - return + if networkSettings == nil { + // First time receiving this callback, so initialize our network settings + networkSettings = NetworkSettings( + packetTunnelProvider: packetTunnelProvider, logger: logger) } - // Connlib's up, set it as the default DNS - networkSettings.setMatchDomains([""]) - networkSettings.apply(on: packetTunnelProvider, logger: self.logger) { error in - if let error = error { - self.logger.error("\(#function): \(error)") - onStarted?(AdapterError.setNetworkSettings(error)) - self.state = .stoppedTunnel - } else { - onStarted?(nil) - self.state = .tunnelReady(session: session) - self.beginPathMonitoring() - } + logger.log( + "\(#function): \(tunnelAddressIPv4) \(tunnelAddressIPv6) \(dnsAddresses)") + + switch state { + case .tunnelStarted(session: _): + guard let networkSettings = networkSettings else { return } + networkSettings.tunnelAddressIPv4 = tunnelAddressIPv4 + networkSettings.tunnelAddressIPv6 = tunnelAddressIPv6 + networkSettings.dnsAddresses = dnsAddresses + networkSettings.apply() + case .tunnelStopped: + logger.error( + "\(#function): Unexpected state: \(self.state)") } } } public func onUpdateRoutes(routeList4: String, routeList6: String) { + // This is a queued callback to ensure ordering workQueue.async { [weak self] in guard let self = self else { return } - - self.logger.log("Adapter.onUpdateRoutes \(routeList4) \(routeList6)") - - guard let networkSettings = self.networkSettings else { - self.logger.error("Adapter.onUpdateRoutes: No network settings") - return + guard let networkSettings = networkSettings + else { + fatalError("onUpdateRoutes called before network settings was initialized!") } + logger.log("\(#function): \(routeList4) \(routeList6)") + networkSettings.routes4 = try! JSONDecoder().decode( [NetworkSettings.Cidr].self, from: routeList4.data(using: .utf8)! ).compactMap { $0.asNEIPv4Route } networkSettings.routes6 = try! JSONDecoder().decode( [NetworkSettings.Cidr].self, from: routeList6.data(using: .utf8)! ).compactMap { $0.asNEIPv6Route } - networkSettings.hasUnappliedChanges = true - networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) + networkSettings.apply() } } public func onUpdateResources(resourceList: String) { + // This is a queued callback to ensure ordering workQueue.async { [weak self] in guard let self = self else { return } - self.logger.log("Adapter.onUpdateResources") - let jsonString = resourceList - guard let jsonData = jsonString.data(using: .utf8) else { - return - } - guard let networkResources = try? JSONDecoder().decode([NetworkResource].self, from: jsonData) - else { - return - } + logger.log("\(#function)") - // Note down the resources - self.displayableResources.update(resources: networkResources.map { $0.displayableResource }) - - // Update DNS in case resource domains is changing - guard let networkSettings = self.networkSettings else { - self.logger.error("Adapter.onUpdateResources: No network settings") - return - } - let updatedResourceDomains = networkResources.compactMap { $0.resourceLocation.domain } - networkSettings.setResourceDomains(updatedResourceDomains) - if case .tunnelReady = self.state { - networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil) - } + // Update resource List. We don't care what's inside. + resourceListJSON = resourceList } } public func onDisconnect(error: String) { + // Since connlib has already shutdown by this point, we queue this callback + // to ensure that we can clean up even if connlib exits before we are done. workQueue.async { [weak self] in guard let self = self else { return } + logger.log("\(#function)") - self.logger.error( - "Connlib disconnected with unrecoverable error: \(error)") - switch self.state { - case .stoppingTunnel(session: _, let onStopped): - onStopped?() - self.state = .stoppedTunnel - case .stoppingTunnelTemporarily(session: _, let onStopped): - onStopped?() - self.state = .stoppedTunnel - case .stoppedTunnel: - // This should not happen - break - case .stoppedTunnelTemporarily: - self.state = .stoppedTunnel - default: - packetTunnelProvider?.handleTunnelShutdown( - dueTo: .connlibDisconnected, - errorMessage: error) - self.packetTunnelProvider?.cancelTunnelWithError( - AdapterError.connlibFatalError(error)) - self.state = .stoppedTunnel + // Set a default stop reason. In the future, we may have more to act upon in + // different ways. + var reason: NEProviderStopReason = .connectionFailed + + // connlib-initiated -- session is already disconnected, move directly to .tunnelStopped + // provider will call our stop() at the end. + state = .tunnelStopped + + // HACK: Define more connlib error types across the FFI so we can switch on them + // directly and not parse error strings here. + if error.contains("401 Unauthorized") { + reason = .authenticationCanceled } + + // Start the process of telling the system to shut us down + self.packetTunnelProvider?.stopTunnel(with: reason) {} } } + + private func getSystemDefaultResolvers(interfaceName: String? = nil) -> [String] { + #if os(macOS) + let resolvers = SystemConfigurationResolvers(logger: logger).getDefaultDNSServers( + interfaceName: interfaceName) + #elseif os(iOS) + let resolvers = resetToSystemDNSGettingBindResolvers() + #endif + + return resolvers + } } + +// MARK: Getting System Resolvers on iOS +#if os(iOS) + extension Adapter { + // When the tunnel is up, we can only get the system's default resolvers + // by reading /etc/resolv.conf when matchDomains is set to a non-empty string. + // If matchDomains is an empty string, /etc/resolv.conf will contain connlib's + // sentinel, which isn't helpful to us. + private func resetToSystemDNSGettingBindResolvers() -> [String] { + guard let networkSettings = networkSettings + else { + // Network Settings hasn't been applied yet, so our sentinel isn't + // the system's resolver and we can grab the system resolvers directly. + // If we try to continue below without valid tunnel addresses assigned + // to the interface, we'll crash. + return BindResolvers().getservers().map(BindResolvers.getnameinfo) + } + + var resolvers: [String] = [] + + // The caller is in an async context, so it's ok to block this thread here. + let semaphore = DispatchSemaphore(value: 0) + + // Set tunnel's matchDomains to a dummy string that will never match any name + networkSettings.matchDomains = ["firezone-fd0020211111"] + + // Call apply to populate /etc/resolv.conf with the system's default resolvers + networkSettings.apply { + guard let networkSettings = self.networkSettings else { return } + + // Only now can we get the system resolvers + resolvers = BindResolvers().getservers().map(BindResolvers.getnameinfo) + + // Restore connlib's DNS resolvers + networkSettings.matchDomains = [""] + networkSettings.apply { semaphore.signal() } + } + + semaphore.wait() + return resolvers + } + } +#endif diff --git a/swift/apple/FirezoneNetworkExtension/Resolv.swift b/swift/apple/FirezoneNetworkExtension/BindResolvers.swift similarity index 95% rename from swift/apple/FirezoneNetworkExtension/Resolv.swift rename to swift/apple/FirezoneNetworkExtension/BindResolvers.swift index 4d50c991e..b47681b47 100644 --- a/swift/apple/FirezoneNetworkExtension/Resolv.swift +++ b/swift/apple/FirezoneNetworkExtension/BindResolvers.swift @@ -6,7 +6,7 @@ // // Reads system resolvers from libresolv, similar to reading /etc/resolv.conf but this also works on iOS -public class Resolv { +public class BindResolvers { var state = __res_9_state() public init() { @@ -27,7 +27,7 @@ public class Resolv { } } -extension Resolv { +extension BindResolvers { public static func getnameinfo(_ s: res_9_sockaddr_union) -> String { var s = s var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) diff --git a/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift b/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift index acaa2d48d..5b780ca93 100644 --- a/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift +++ b/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift @@ -22,7 +22,6 @@ public protocol CallbackHandlerDelegate: AnyObject { tunnelAddressIPv6: String, dnsAddresses: [String] ) - func onTunnelReady() func onUpdateRoutes(routeList4: String, routeList6: String) func onUpdateResources(resourceList: String) func onDisconnect(error: String) @@ -30,12 +29,12 @@ public protocol CallbackHandlerDelegate: AnyObject { public class CallbackHandler { public weak var delegate: CallbackHandlerDelegate? - private var systemDefaultResolvers: [String] = [] private let logger: AppLogger init(logger: AppLogger) { self.logger = logger } + func onSetInterfaceConfig( tunnelAddressIPv4: RustString, tunnelAddressIPv6: RustString, @@ -49,13 +48,8 @@ public class CallbackHandler { DNS: \(dnsAddresses.toString()) """) - guard let dnsData = dnsAddresses.toString().data(using: .utf8) else { - return - } - guard let dnsArray = try? JSONDecoder().decode([String].self, from: dnsData) - else { - return - } + let dnsData = dnsAddresses.toString().data(using: .utf8)! + let dnsArray = try! JSONDecoder().decode([String].self, from: dnsData) delegate?.onSetInterfaceConfig( tunnelAddressIPv4: tunnelAddressIPv4.toString(), @@ -64,11 +58,6 @@ public class CallbackHandler { ) } - func onTunnelReady() { - logger.log("CallbackHandler.onTunnelReady") - delegate?.onTunnelReady() - } - func onUpdateRoutes(routeList4: RustString, routeList6: RustString) { logger.log("CallbackHandler.onUpdateRoutes: \(routeList4) \(routeList6)") delegate?.onUpdateRoutes(routeList4: routeList4.toString(), routeList6: routeList6.toString()) @@ -84,21 +73,4 @@ public class CallbackHandler { logger.log("CallbackHandler.onDisconnect: \(error)") delegate?.onDisconnect(error: error) } - - func setSystemDefaultResolvers(resolvers: [String]) { - logger.log( - "CallbackHandler.setSystemDefaultResolvers: \(resolvers)") - self.systemDefaultResolvers = resolvers - } - - func getSystemDefaultResolvers() -> RustString { - logger.log( - "CallbackHandler.getSystemDefaultResolvers: \(self.systemDefaultResolvers)" - ) - - return try! String( - decoding: JSONEncoder().encode(self.systemDefaultResolvers), - as: UTF8.self - ).intoRustString() - } } diff --git a/swift/apple/FirezoneNetworkExtension/DeviceMetadata.swift b/swift/apple/FirezoneNetworkExtension/DeviceMetadata.swift new file mode 100644 index 000000000..bdffb70ec --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/DeviceMetadata.swift @@ -0,0 +1,57 @@ +// +// DeviceMetadata.swift +// Firezone +// +// Created by Jamil Bou Kheir on 2/23/24. +// + +import FirezoneKit +import Foundation + +#if os(iOS) + import UIKit.UIDevice +#endif + +public class DeviceMetadata { + public static func getDeviceName() -> String? { + // Returns a generic device name on iOS 16 and higher + // See https://github.com/firezone/firezone/issues/3034 + #if os(iOS) + return UIDevice.current.name + #else + // Fallback to connlib's gethostname() + return nil + #endif + } + + public static func getOSVersion() -> String? { + // Returns the OS version + // See https://github.com/firezone/firezone/issues/3034 + return ProcessInfo.processInfo.operatingSystemVersionString + } + + // Returns the Firezone ID as cached by the application or generates and persists a new one + // if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device + // for upsert and identification in the admin portal. + public static func getOrCreateFirezoneId(logger: AppLogger) -> String { + let fileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id") + + do { + return try String(contentsOf: fileURL, encoding: .utf8) + } catch { + // Handle the error if the file doesn't exist or isn't readable + // Recreate the file, save a new UUIDv4, and return it + let newUUIDString = UUID().uuidString + + do { + try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + logger.error( + "\(#function): Could not save firezone-id file \(fileURL.path)! Error: \(error)" + ) + } + + return newUUIDString + } + } +} diff --git a/swift/apple/FirezoneNetworkExtension/NetworkResource.swift b/swift/apple/FirezoneNetworkExtension/NetworkResource.swift deleted file mode 100644 index 56fde5e69..000000000 --- a/swift/apple/FirezoneNetworkExtension/NetworkResource.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// NetworkResource.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import FirezoneKit -import Foundation - -public struct NetworkResource: Decodable { - enum ResourceLocation { - case dns(domain: String) - case cidr(cidrAddress: String) - - func toString() -> String { - switch self { - case .dns(let domain): return domain - case .cidr(let cidrAddress): return cidrAddress - } - } - - var domain: String? { - switch self { - case .dns(let domain): return domain - case .cidr: return nil - } - } - } - - let name: String - let resourceLocation: ResourceLocation - - var displayableResource: DisplayableResources.Resource { - DisplayableResources.Resource(name: name, location: resourceLocation.toString()) - } -} - -// A DNS resource example: -// { -// "type": "dns", -// "address": "app.posthog.com", -// "name": "PostHog", -// } -// -// A CIDR resource example: -// { -// "type": "cidr", -// "address": "10.0.0.0/24", -// "name": "AWS SJC VPC1", -// } - -extension NetworkResource { - enum ResourceKeys: String, CodingKey { - case type - case address - case name - } - - enum DecodeError: Error { - case invalidType(String) - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ResourceKeys.self) - let name = try container.decode(String.self, forKey: .name) - let type = try container.decode(String.self, forKey: .type) - let resourceLocation: ResourceLocation = try { - switch type { - case "dns": - let domain = try container.decode(String.self, forKey: .address) - return .dns(domain: domain) - case "cidr": - let address = try container.decode(String.self, forKey: .address) - return .cidr(cidrAddress: address) - default: - throw DecodeError.invalidType(type) - } - }() - self.init(name: name, resourceLocation: resourceLocation) - } -} diff --git a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift index 62e062b22..d6e1b1f8d 100644 --- a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift +++ b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift @@ -9,108 +9,56 @@ import NetworkExtension import os.log class NetworkSettings { - - // Unchanging values - let tunnelAddressIPv4: String - let tunnelAddressIPv6: String - let dnsAddresses: [String] - // WireGuard has an 80-byte overhead. We could try setting tunnelOverheadBytes // but that's not a reliable way to calculate how big our packets should be, // so just use the minimum. let mtu: NSNumber = 1280 - public var routes4: [NEIPv4Route] = [] - public var routes6: [NEIPv6Route] = [] + // These will only be initialized once and then don't change + private weak var packetTunnelProvider: NEPacketTunnelProvider? + private(set) var logger: AppLogger // Modifiable values - private(set) var resourceDomains: [String] = [] - private(set) var matchDomains: [String] = [""] + public var tunnelAddressIPv4: String? + public var tunnelAddressIPv6: String? + public var dnsAddresses: [String] = [] + public var routes4: [NEIPv4Route] = [] + public var routes6: [NEIPv6Route] = [] + public var matchDomains: [String] = [""] - // To keep track of modifications - public var hasUnappliedChanges: Bool - - init( - tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddresses: [String] - ) { - self.tunnelAddressIPv4 = tunnelAddressIPv4 - self.tunnelAddressIPv6 = tunnelAddressIPv6 - self.dnsAddresses = dnsAddresses - self.hasUnappliedChanges = true + init(packetTunnelProvider: PacketTunnelProvider?, logger: AppLogger) { + self.packetTunnelProvider = packetTunnelProvider + self.logger = logger } - func setResourceDomains(_ resourceDomains: [String]) { - let sortedResourceDomains = resourceDomains.sorted() - if self.resourceDomains != sortedResourceDomains { - self.resourceDomains = sortedResourceDomains - } - self.hasUnappliedChanges = true - } - - func setMatchDomains(_ matchDomains: [String]) { - self.matchDomains = matchDomains - self.hasUnappliedChanges = true - } - - func apply( - on packetTunnelProvider: NEPacketTunnelProvider?, - logger: AppLogger, - completionHandler: ((Error?) -> Void)? - ) { - guard let packetTunnelProvider = packetTunnelProvider else { - logger.error("\(#function): packetTunnelProvider not initialized! This should not happen.") - return - } - - guard self.hasUnappliedChanges else { - logger.error("NetworkSettings.apply: No changes to apply") - completionHandler?(nil) - return - } - - logger.log("NetworkSettings.apply: Applying network settings") + func apply(completionHandler: (() -> Void)? = nil) { // We don't really know the connlib gateway IP address at this point, but just using 127.0.0.1 is okay // because the OS doesn't really need this IP address. // NEPacketTunnelNetworkSettings taking in tunnelRemoteAddress is probably a bad abstraction caused by // NEPacketTunnelNetworkSettings inheriting from NETunnelNetworkSettings. - let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") - let ipv4Settings = NEIPv4Settings( - addresses: [tunnelAddressIPv4], subnetMasks: ["255.255.255.255"]) - ipv4Settings.includedRoutes = self.routes4 - tunnelNetworkSettings.ipv4Settings = ipv4Settings - - let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6], networkPrefixLengths: [128]) - ipv6Settings.includedRoutes = self.routes6 - tunnelNetworkSettings.ipv6Settings = ipv6Settings - + // Set tunnel addresses and routes + let ipv4Settings = NEIPv4Settings(addresses: [tunnelAddressIPv4!], subnetMasks: ["255.255.255.255"]) + let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6!], networkPrefixLengths: [128]) let dnsSettings = NEDNSSettings(servers: dnsAddresses) - // Intercept all DNS queries; SplitDNS will be handled by connlib + ipv4Settings.includedRoutes = routes4 + ipv6Settings.includedRoutes = routes6 dnsSettings.matchDomains = matchDomains dnsSettings.matchDomainsNoSearch = true + tunnelNetworkSettings.ipv4Settings = ipv4Settings + tunnelNetworkSettings.ipv6Settings = ipv6Settings tunnelNetworkSettings.dnsSettings = dnsSettings tunnelNetworkSettings.mtu = mtu - self.hasUnappliedChanges = false - logger.log("Attempting to set network settings") - packetTunnelProvider.setTunnelNetworkSettings(tunnelNetworkSettings) { error in + packetTunnelProvider?.setTunnelNetworkSettings(tunnelNetworkSettings) { error in if let error = error { - logger.error("NetworkSettings.apply: Error: \(error)") - } else { - guard !self.hasUnappliedChanges else { - // Changes were made while the packetTunnelProvider was setting the network settings - logger.log( - """ - NetworkSettings.apply: - Applying changes made to network settings while we were applying the network settings - """) - self.apply(on: packetTunnelProvider, logger: logger, completionHandler: completionHandler) - return - } - logger.log("NetworkSettings.apply: Applied successfully") + self.logger.error( + "\(#function): Error occurred while applying network settings! Error: \(error.localizedDescription)" + ) } - completionHandler?(error) + + completionHandler?() } } } @@ -154,6 +102,8 @@ enum IPv4SubnetMaskLookup { ] } +// Route convenience helpers. Data is from connlib and guaranteed to be valid. +// Otherwise, we should crash and learn about it. extension NetworkSettings { struct Cidr: Codable { let address: String diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index d514babb0..4e34782da 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -12,11 +12,10 @@ import os enum PacketTunnelProviderError: Error { case savedProtocolConfigurationIsInvalid(String) case tokenNotFoundInKeychain - case couldNotSetNetworkSettings } class PacketTunnelProvider: NEPacketTunnelProvider { - let logger = AppLogger(process: .tunnel, folderURL: SharedAccess.tunnelLogFolderURL) + let logger = AppLogger(category: .tunnel, folderURL: SharedAccess.tunnelLogFolderURL) private var adapter: Adapter? @@ -26,57 +25,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider { ) { logger.log("\(#function)") - guard let controlPlaneURLString = protocolConfiguration.serverAddress else { - logger.error("serverAddress is missing") - self.handleTunnelShutdown( - dueTo: .badTunnelConfiguration, - errorMessage: "serverAddress is missing") + guard let apiURL = protocolConfiguration.serverAddress, + let tokenRef = protocolConfiguration.passwordReference, + let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? + .providerConfiguration as? [String: String], + let logFilter = providerConfiguration[TunnelStoreKeys.logFilter] + else { completionHandler( PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("serverAddress")) return } - guard let tokenRef = protocolConfiguration.passwordReference else { - logger.error("passwordReference is missing") - self.handleTunnelShutdown( - dueTo: .badTunnelConfiguration, - errorMessage: "passwordReference is missing") - completionHandler( - PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("passwordReference")) - return - } - - let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration - - guard let connlibLogFilter = providerConfig?[TunnelProviderKeys.keyConnlibLogFilter] as? String - else { - logger.error("connlibLogFilter is missing") - self.handleTunnelShutdown( - dueTo: .badTunnelConfiguration, - errorMessage: "connlibLogFilter is missing") - completionHandler( - PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("connlibLogFilter")) - return - } - Task { let keychain = Keychain() guard let token = await keychain.load(persistentRef: tokenRef) else { - self.handleTunnelShutdown( - dueTo: .tokenNotFound, - errorMessage: "Token not found in keychain") + logger.error("\(#function): No token found in Keychain") completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain) return } let adapter = Adapter( - controlPlaneURLString: controlPlaneURLString, token: token, logFilter: connlibLogFilter, + apiURL: apiURL, + token: token, + logFilter: logFilter, packetTunnelProvider: self) self.adapter = adapter do { try adapter.start { error in if let error { - self.logger.error("Error in adapter.start: \(error)") + self.logger.error("\(#function): \(error)") } completionHandler(error) } @@ -86,36 +63,43 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + // This can be called by the system, or initiated by connlib. + // When called by the system, we call Adapter.stop() from here. + // When initiated by connlib, we've already called stop() there. override func stopTunnel( with reason: NEProviderStopReason, completionHandler: @escaping () -> Void ) { logger.log("stopTunnel: Reason: \(reason)") - adapter?.stop(reason: reason) { - completionHandler() - #if os(macOS) - // HACK: This is a filthy hack to work around Apple bug 32073323 - exit(0) + + if case .authenticationCanceled = reason { + do { + // Remove the passwordReference from our configuration so that it's not used again + // if the app is re-launched. There's no good way to send data like this from the + // Network Extension to the GUI, so save it to a file for the GUI to read later. + try String(reason.rawValue).write(to: SharedAccess.providerStopReasonURL, atomically: true, encoding: .utf8) + } catch { + logger.error("\(#function): Couldn't write provider stop reason to file. Notification won't work.") + } + #if os(iOS) + // iOS notifications should be shown from the tunnel process + SessionNotificationHelper.showSignedOutNotificationiOS(logger: self.logger) #endif } + + // handles both connlib-initiated and user-initiated stops + adapter?.stop() + + cancelTunnelWithError(nil) } - override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) { - let query = String(data: messageData, encoding: .utf8) ?? "" - adapter?.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) { - displayableResources in - completionHandler?(displayableResources?.toData()) + // TODO: Use a message format to allow requesting different types of data. + // This currently assumes we're requesting resources. + override func handleAppMessage(_ hash: Data, completionHandler: ((Data?) -> Void)? = nil) { + adapter?.getResourcesIfVersionDifferentFrom(hash: hash) { + resourceListJSON in + completionHandler?(resourceListJSON?.data(using: .utf8)) } } - - func handleTunnelShutdown(dueTo reason: TunnelShutdownEvent.Reason, errorMessage: String) { - TunnelShutdownEvent.saveToDisk(reason: reason, errorMessage: errorMessage, logger: self.logger) - - #if os(iOS) - if reason.action == .signoutImmediately { - SessionNotificationHelper.showSignedOutNotificationiOS(logger: self.logger) - } - #endif - } } extension NEProviderStopReason: CustomStringConvertible { @@ -127,7 +111,7 @@ extension NEProviderStopReason: CustomStringConvertible { case .noNetworkAvailable: return "No network available" case .unrecoverableNetworkChange: return "Unrecoverable network change" case .providerDisabled: return "Provider disabled" - case .authenticationCanceled: return "Authentication cancelled" + case .authenticationCanceled: return "Authentication canceled" case .configurationFailed: return "Configuration failed" case .idleTimeout: return "Idle timeout" case .configurationDisabled: return "Configuration disabled" diff --git a/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift b/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift new file mode 100644 index 000000000..fb153f534 --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift @@ -0,0 +1,124 @@ +// +// SystemConfiguration.swift +// FirezoneNetworkExtensionmacOS +// +// Created by Jamil Bou Kheir on 2/26/24. +// + +import FirezoneKit +import Foundation +import SystemConfiguration + +class SystemConfigurationResolvers { + private let logger: AppLogger + private var dynamicStore: SCDynamicStore? + + // Arbitrary name for the connection to the store + private let storeName = "dev.firezone.firezone.dns" as CFString + + init(logger: AppLogger) { + self.logger = logger + guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil) + else { + logger.error("\(#function): Failed to create dynamic store") + self.dynamicStore = nil + return + } + + self.dynamicStore = dynamicStore + } + + func getDefaultDNSServers(interfaceName: String?) -> [String] { + if let interfaceName = interfaceName { + return getDefaultDNSServersForInterface(interfaceName) + } else { + return getGlobalDefaultDNSServers() + } + } + + + /// 1. First, find the service ID that corresponds to the interface we're interested in. + /// We do this by searching the configuration store at "Setup:/Network/Service//Interface" + /// for a matching "InterfaceName". + /// 2. When we get a hit, save the service id we found. + /// 3. The DNS ServerAddresses can be found in two places: + /// * If the user has manually overridden the DNS servers for an interface, + /// they'll be at "Setup:/Network/Service//DNS" + /// * If they haven't, then the DHCP server addresses can be found at + /// State:/Network/Service//DNS + /// 4. We assume manually-set DNS servers take precedence over DHCP ones, + /// so return those if found. Otherwise, return the DHCP ones. + private func getDefaultDNSServersForInterface(_ interfaceName: String) -> [String] { + guard let dynamicStore = dynamicStore + else { + return [] + } + + let interfaceSearchKey = "Setup:/Network/Service/.*/Interface" as CFString + guard let services = SCDynamicStoreCopyKeyList(dynamicStore, interfaceSearchKey) as? [String] + else { + logger.error("\(#function): Unable to retrieve network services") + return [] + } + + // Loop over all the services found, checking for the one we want + for service in services { + guard let configInterfaceName = fetch(path: service, key: "DeviceName") as? String, + configInterfaceName == interfaceName + else { continue } + + // Extract our serviceId + let serviceId = service.split(separator: "/")[3] + + // Try to get any manually-assigned DNS servers + let manualDnsPath = "Setup:/Network/Service/\(serviceId)/DNS" + if let serverAddresses = fetch(path: manualDnsPath, key: "ServerAddresses") as? [String] { + return serverAddresses + } + + // None found. Try getting the DHCP ones instead. + let dhcpDnsPath = "State:/Network/Service/\(serviceId)/DNS" + if let serverAddresses = fetch(path: dhcpDnsPath, key: "ServerAddresses") as? [String] { + return serverAddresses + } + } + + // Otherwise, we failed + return [] + } + + private func fetch(path: String, key: String) -> Any? { + guard let dynamicStore = dynamicStore, + let result = SCDynamicStoreCopyValue(dynamicStore, path as CFString), + let value = result[key] + else { return nil } + + return value + } + + private func getGlobalDefaultDNSServers() -> [String] { + guard let dynamicStore = dynamicStore + else { + return [] + } + + var dnsServers: [String] = [] + + // Specify the DNS key to fetch the current DNS servers + let dnsKey = "State:/Network/Global/DNS" as CFString + + // Retrieve the current DNS server configuration from the dynamic store + guard let dnsInfo = SCDynamicStoreCopyValue(dynamicStore, dnsKey) as? [String: Any], + let servers = dnsInfo[kSCPropNetDNSServerAddresses as String] as? [String] + else { + self.logger.error("\(#function): Failed to retrieve DNS server information") + return [] + } + + // Append the retrieved DNS servers to the result array + dnsServers.append(contentsOf: servers) + + return dnsServers + + } +} diff --git a/swift/apple/README.md b/swift/apple/README.md index ff9ccdf76..f7ea9f7f1 100644 --- a/swift/apple/README.md +++ b/swift/apple/README.md @@ -110,3 +110,12 @@ cd swift/apple ``` rm -rf $HOME/Library/Group\ Containers/47R2M6779T.group.dev.firezone.firezone/Library/Caches/logs/connlib ``` + +### Clearing the Keychain item + +Sometimes it's helpful to be able to test how the app behaves when the keychain +item is missing. You can remove the keychain item with the following command: + +```bash +security delete-generic-password -s "dev.firezone.firezone" +```