From 525c1b502be10308517437f6b118b86c8055683f Mon Sep 17 00:00:00 2001 From: Jamil Date: Fri, 3 Jan 2025 16:59:59 -0800 Subject: [PATCH] feat(apple): Add Sentry reporting to Swift codebase (#7620) Adds Sentry SDK to the Swift codebase. Currently error captures are implemented and happen during `Log.error` calls. Fixes #7560 --- .github/workflows/sentry.yml | 46 +++------ .../apple/Firezone.xcodeproj/project.pbxproj | 36 +++++++ .../xcshareddata/swiftpm/Package.resolved | 14 +++ .../Firezone/Application/FirezoneApp.swift | 8 +- .../Sources/FirezoneKit/Helpers/Log.swift | 7 +- .../FirezoneKit/Helpers/SharedAccess.swift | 11 +++ .../FirezoneKit/Helpers/Telemetry.swift | 98 +++++++++++++++++++ .../FirezoneKit/Managers/TunnelManager.swift | 46 ++++++--- .../FirezoneKit/Models/AuthClient.swift | 30 +++--- .../FirezoneKit/Models/AuthResponse.swift | 8 +- .../FirezoneKit/Models/FirezoneId.swift | 17 ++-- .../Models/SessionNotification.swift | 4 +- .../FirezoneKit/Models/WebAuthSession.swift | 8 +- .../Sources/FirezoneKit/Stores/Store.swift | 4 +- .../FirezoneKit/Views/GrantVPNView.swift | 2 +- .../Sources/FirezoneKit/Views/MenuBar.swift | 2 +- .../FirezoneKit/Views/SettingsView.swift | 28 ++++-- .../Views/UpdateNotification.swift | 24 +++-- .../FirezoneNetworkExtension/Adapter.swift | 24 +++-- .../NetworkSettings.swift | 4 +- .../PacketTunnelProvider.swift | 79 ++++++++------- .../SystemConfigurationResolvers.swift | 17 +++- swift/apple/PrivacyInfo.xcprivacy | 69 +++++++++++++ 23 files changed, 443 insertions(+), 143 deletions(-) create mode 100644 swift/apple/Firezone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift diff --git a/.github/workflows/sentry.yml b/.github/workflows/sentry.yml index 408f2c500..7fb732ef4 100644 --- a/.github/workflows/sentry.yml +++ b/.github/workflows/sentry.yml @@ -10,41 +10,27 @@ concurrency: cancel-in-progress: false jobs: - create_gateway_sentry_release: - if: ${{ startsWith(github.event.release.name, 'gateway') }} + create_sentry_release: + name: create_${{ matrix.component }}_sentry_release runs-on: ubuntu-22.04 + strategy: + matrix: + include: + - component: gateway + projects: gateway + - component: gui-client + projects: gui-client-gui gui-client-ipc-service + - component: headless-client + projects: headless-client + - component: macos-client + projects: apple-client steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: ./.github/actions/create-sentry-release + if: ${{ startsWith(github.event.release.name, matrix.component) }} with: - component: gateway - projects: gateway - sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }} - - create_gui-client_sentry_release: - if: ${{ startsWith(github.event.release.name, 'gui-client') }} - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: ./.github/actions/create-sentry-release - with: - component: gui-client - projects: gui-client-gui gui-client-ipc-service - sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }} - - create_headless-client_sentry_release: - if: ${{ startsWith(github.event.release.name, 'headless-client') }} - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: ./.github/actions/create-sentry-release - with: - component: headless-client - projects: headless-client + component: ${{ matrix.component }} + projects: ${{ matrix.projects }} sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index b9fab7016..9650eebd2 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; 794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; }; 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; }; + 8D4087D52D24653B005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D42D24653B005B2BAF /* Sentry */; }; + 8D4087D92D246541005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D82D246541005B2BAF /* Sentry */; }; + 8D4087DD2D246651005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087DC2D246651005B2BAF /* Sentry */; }; 8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; }; 8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; }; 8D5047F52CE6AA1E009802E9 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D5047F32CE6AA1E009802E9 /* libresolv.9.tbd */; }; @@ -129,6 +132,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8D4087D52D24653B005B2BAF /* Sentry in Frameworks */, 8DC08BD42B297B8200675F46 /* libresolv.tbd in Frameworks */, 8DC08BCB2B296C4500675F46 /* libresolv.9.tbd in Frameworks */, 794C38152970A2660029F38F /* FirezoneKit in Frameworks */, @@ -145,6 +149,7 @@ 8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */, 8DC9FB852CF5A738001BCE6A /* NetworkExtension.framework in Frameworks */, 8D5047FA2CE6AA2E009802E9 /* SystemConfiguration.framework in Frameworks */, + 8D4087D92D246541005B2BAF /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -154,6 +159,7 @@ files = ( 05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */, 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */, + 8D4087DD2D246651005B2BAF /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -276,6 +282,7 @@ name = FirezoneNetworkExtensioniOS; packageProductDependencies = ( 794C38142970A2660029F38F /* FirezoneKit */, + 8D4087D42D24653B005B2BAF /* Sentry */, ); productName = FirezoneNetworkExtensioniOS; productReference = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */; @@ -298,6 +305,7 @@ name = FirezoneNetworkExtensionmacOS; packageProductDependencies = ( 8D5047F72CE6AA22009802E9 /* FirezoneKit */, + 8D4087D82D246541005B2BAF /* Sentry */, ); productName = FirezoneNetworkExtensionStandalonemacOS; productReference = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */; @@ -323,6 +331,7 @@ name = Firezone; packageProductDependencies = ( 79756C6529704A7A0018E2D5 /* FirezoneKit */, + 8D4087DC2D246651005B2BAF /* Sentry */, ); productName = Firezone; productReference = 8DCC021928D512AC007E12D2 /* Firezone.app */; @@ -361,6 +370,7 @@ ); mainGroup = 8DCC021028D512AC007E12D2; packageReferences = ( + 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */, ); productRefGroup = 8DCC021A28D512AC007E12D2 /* Products */; projectDirPath = ""; @@ -986,6 +996,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.42.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 794C38142970A2660029F38F /* FirezoneKit */ = { isa = XCSwiftPackageProductDependency; @@ -995,6 +1016,21 @@ isa = XCSwiftPackageProductDependency; productName = FirezoneKit; }; + 8D4087D42D24653B005B2BAF /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; + 8D4087D82D246541005B2BAF /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; + 8D4087DC2D246651005B2BAF /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; 8D5047F72CE6AA22009802E9 /* FirezoneKit */ = { isa = XCSwiftPackageProductDependency; productName = FirezoneKit; diff --git a/swift/apple/Firezone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/apple/Firezone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..c7cb22a32 --- /dev/null +++ b/swift/apple/Firezone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "c1404acfa3a71e42aa789c6d5564da59d4377569", + "version" : "8.42.1" + } + } + ], + "version" : 2 +} diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 098aa2482..b1c13fb6a 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -18,6 +18,9 @@ struct FirezoneApp: App { @StateObject var store: Store init() { + // Initialize Telemetry as early as possible + Telemetry.start() + let favorites = Favorites() let store = Store() _favorites = StateObject(wrappedValue: favorites) @@ -80,7 +83,10 @@ struct FirezoneApp: App { // Can be removed once all clients >= 1.4.0 try await FirezoneId.migrate() - try await FirezoneId.createIfMissing() + let id = try await FirezoneId.createIfMissing() + + // Hydrate telemetry userId with our firezone id + Telemetry.setFirezoneId(id.uuid.uuidString) } if let store = store { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift index 37df61696..860bd69ea 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift @@ -50,9 +50,10 @@ public final class Log { logWriter?.write(severity: .warning, message: message) } - public static func error(_ message: String) { - self.logger.error("\(message, privacy: .public)") - logWriter?.write(severity: .error, message: message) + public static func error(_ err: Error) { + self.logger.error("\(err.localizedDescription, privacy: .public)") + logWriter?.write(severity: .error, message: err.localizedDescription) + Telemetry.capture(err) } // Returns the size in bytes of the provided directory, calculated by summing diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift index e5b5f97f2..90d3a8e4f 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SharedAccess.swift @@ -7,6 +7,17 @@ import Foundation public struct SharedAccess { + public enum Error: Swift.Error { + case unableToWriteToFile(URL, Swift.Error) + + var localizedDescription: String { + switch self { + case .unableToWriteToFile(let url, let error): + return "Unable to write to \(url): \(error)" + } + } + } + public static var baseFolderURL: URL { guard let url = FileManager.default.containerURL( diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift new file mode 100644 index 000000000..80f437bde --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift @@ -0,0 +1,98 @@ +// +// Telemetry.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Sentry + +public enum Telemetry { + // We can only create a new User object after Sentry is started; not retrieve + // the existing one. So we need to collect these fields from various codepaths + // during initialization / sign in so we can build a new User object any time + // one of these is updated. + private static var userId: String? + private static var accountSlug: String? + + public static func start() { + SentrySDK.start { options in + options.dsn = "https://66c71f83675f01abfffa8eb977bcbbf7@o4507971108339712.ingest.us.sentry.io/4508175177023488" + options.environment = "entrypoint" // will be reconfigured in TunnelManager + options.releaseName = releaseName() + +#if DEBUG + // https://docs.sentry.io/platforms/apple/guides/ios/configuration/options/#debug + options.debug = true +#endif + } + } + + public static func setEnvironmentOrClose(_ apiURLString: String) { + var environment: String? + + if apiURLString.starts(with: "wss://api.firezone.dev") { + environment = "production" + } else if apiURLString.starts(with: "wss://api.firez.one") { + environment = "staging" + } + + guard let environment + else { + // Disable Sentry in unknown environments + SentrySDK.close() + + return + } + + SentrySDK.configureScope { configuration in + configuration.setEnvironment(environment) + } + } + + public static func capture(_ err: Error) { + SentrySDK.capture(error: err) + } + + public static func setFirezoneId(_ id: String?) { + self.userId = id + updateUser() + } + + public static func setAccountSlug(_ slug: String?) { + self.accountSlug = slug + updateUser() + } + + private static func updateUser() { + guard let userId, + let accountSlug + else { + return + } + + SentrySDK.configureScope { configuration in + // Matches the format we use in rust/telemetry/lib.rs + let user = User(userId: userId) + user.data = ["account_slug": accountSlug] + + configuration.setUser(user) + } + } + + private static func releaseName() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] + as? String ?? "unknown" + +#if os(iOS) + return "ios-appstore-\(version)" +#else + // Apps from the app store have a receipt file + if let receiptURL = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: receiptURL.path) { + return "macos-appstore-\(version)" + } + + return "macos-client-\(version)" +#endif + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index 8d108790d..43e2d0a88 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -13,13 +13,29 @@ import NetworkExtension enum TunnelManagerError: Error { case cannotSaveIfMissing + case cannotLoad case decodeIPCDataFailed + case invalidStatusChange + + var localizedDescription: String { + switch self { + case .cannotSaveIfMissing: + return "Manager doesn't seem initialized. Can't save settings." + case .decodeIPCDataFailed: + return "Decoding IPC data failed." + case .invalidStatusChange: + return "NEVPNStatusDidChange notification doesn't seem to be valid." + case .cannotLoad: + return "Could not load VPN configurations!" + } + } } public enum TunnelManagerKeys { static let actorName = "actorName" static let authBaseURL = "authBaseURL" static let apiURL = "apiURL" + public static let accountSlug = "accountSlug" public static let logFilter = "logFilter" public static let internetResourceEnabled = "internetResourceEnabled" } @@ -152,7 +168,7 @@ public class TunnelManager { // Since our bundle ID can change (by us), find the one that's current and ignore the others. guard let managers = try? await NETunnelProviderManager.loadAllFromPreferences() else { - Log.error("\(#function): Could not load VPN configurations!") + Log.error(TunnelManagerError.cannotLoad) return } @@ -172,6 +188,12 @@ public class TunnelManager { } let status = manager.connection.status + // Configure our Telemetry environment + Telemetry.setEnvironmentOrClose(settings.apiURL) + Telemetry.setAccountSlug( + providerConfiguration[TunnelManagerKeys.accountSlug] + ) + // Share what we found with our caller callback(status, settings, actorName) @@ -194,16 +216,16 @@ public class TunnelManager { } } - func saveActorName(_ actorName: String?) async throws { + func saveAuthResponse(_ authResponse: AuthResponse) async throws { guard let manager = manager, let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, var providerConfiguration = protocolConfiguration.providerConfiguration else { - Log.error("Manager doesn't seem initialized. Can't save settings.") throw TunnelManagerError.cannotSaveIfMissing } - providerConfiguration[TunnelManagerKeys.actorName] = actorName + providerConfiguration[TunnelManagerKeys.actorName] = authResponse.actorName + providerConfiguration[TunnelManagerKeys.accountSlug] = authResponse.accountSlug protocolConfiguration.providerConfiguration = providerConfiguration manager.protocolConfiguration = protocolConfiguration @@ -220,7 +242,6 @@ public class TunnelManager { let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] else { - Log.error("Manager doesn't seem initialized. Can't save settings.") throw TunnelManagerError.cannotSaveIfMissing } @@ -238,6 +259,9 @@ public class TunnelManager { try await manager.saveToPreferences() try await manager.loadFromPreferences() + + // Reconfigure our Telemetry environment in case it changed + Telemetry.setEnvironmentOrClose(settings.apiURL) } func start(token: String? = nil) { @@ -250,7 +274,7 @@ public class TunnelManager { do { try session()?.startTunnel(options: options) } catch { - Log.error("Error starting tunnel: \(error)") + Log.error(error) } } @@ -261,7 +285,7 @@ public class TunnelManager { self.session()?.stopTunnel() } } catch { - Log.error("\(#function): \(error)") + Log.error(error) } } else { session()?.stopTunnel() @@ -294,7 +318,7 @@ public class TunnelManager { callback(self.resourcesListCache) } } catch { - Log.error("Error: sendProviderMessage: \(error)") + Log.error(error) } } @@ -350,7 +374,6 @@ public class TunnelManager { ) { data in guard let data = data else { - Log.error("Error: \(#function): No data received") errorHandler(TunnelManagerError.decodeIPCDataFailed) return @@ -360,7 +383,6 @@ public class TunnelManager { LogChunk.self, from: data ) else { - Log.error("Error: \(#function): Invalid data received") errorHandler(TunnelManagerError.decodeIPCDataFailed) return @@ -374,7 +396,7 @@ public class TunnelManager { } } } catch { - Log.error("Error: \(#function): \(error)") + Log.error(error) } } @@ -434,7 +456,7 @@ public class TunnelManager { ) { guard let session = notification.object as? NETunnelProviderSession else { - Log.error("\(#function): NEVPNStatusDidChange notification doesn't seem to be valid") + Log.error(TunnelManagerError.invalidStatusChange) return } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift index fd520bbf1..fd3e0bec3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift @@ -36,25 +36,23 @@ struct AuthClient { func response(url: URL?) throws -> AuthResponse { guard let url = url, - let returnedState = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "state" })?.value, + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let returnedState = urlComponents.sanitizedQueryParam("state"), areStringsEqualConstantTime(state, returnedState), - let fragment = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "fragment" })? - .value, - let actorName = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "actor_name" })? - .value? - .removingPercentEncoding? - .replacingOccurrences(of: "+", with: " ") + let fragment = urlComponents.sanitizedQueryParam("fragment"), + let actorName = urlComponents.sanitizedQueryParam("actor_name"), + let accountSlug = urlComponents.sanitizedQueryParam("account_slug") else { throw AuthClientError.invalidCallbackURL } let token = nonce + fragment - return AuthResponse(token: token, actorName: actorName) + return AuthResponse( + actorName: actorName, + accountSlug: accountSlug, + token: token + ) } private static func createRandomHexString(byteCount: Int) throws -> String { @@ -101,3 +99,11 @@ extension URL { return components.url ?? self } } + +extension URLComponents { + func sanitizedQueryParam(_ queryParam: String) -> String? { + let value = self.queryItems?.first(where: { $0.name == queryParam })?.value + + return value?.removingPercentEncoding?.replacingOccurrences(of: "+", with: " ") + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift index fbcd68d84..10389ac1a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift @@ -10,11 +10,9 @@ struct AuthResponse { // The user associated with this authResponse. let actorName: String + // The account slug of the account the user signed in to. + let accountSlug: String + // The opaque auth token let token: String - - init(token: String, actorName: String) { - self.actorName = actorName - self.token = token - } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift index 91718b1d4..5de8ba22e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift @@ -15,7 +15,7 @@ public struct FirezoneId { kSecAttrDescription: "Firezone device id", ] - private var uuid: UUID + public var uuid: UUID public init(_ uuid: UUID? = nil) { self.uuid = uuid ?? UUID() @@ -91,12 +91,17 @@ public struct FirezoneId { try await firezoneId.save() } - public static func createIfMissing() async throws { - guard try await load() == nil - else { return } // New firezone-id already saved in Keychain + public static func createIfMissing() async throws -> FirezoneId { + guard let id = try await load() + else { + let id = FirezoneId(UUID()) + try await id.save() - let firezoneId = FirezoneId(UUID()) - try await firezoneId.save() + return id + } + + // New firezone-id already saved in Keychain + return id } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/SessionNotification.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/SessionNotification.swift index 71c210633..036f895ab 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/SessionNotification.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/SessionNotification.swift @@ -97,9 +97,9 @@ public class SessionNotification: NSObject { ) UNUserNotificationCenter.current().add(request) { error in if let error = error { - Log.error("\(#function): Error requesting notification: \(error)") + Log.error(error) } else { - Log.error("\(#function): Successfully requested notification") + Log.debug("\(#function): Successfully requested notification") } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift index 14c481483..873a2bd66 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift @@ -32,7 +32,13 @@ struct WebAuthSession { return } - Task { try await store.signIn(authResponse: authResponse) } + Task { + do { + try await store.signIn(authResponse: authResponse) + } catch { + Log.error(error) + } + } } // Apple weirdness, doesn't seem to be actually used in macOS diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index e207394e2..3e981a2c9 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -126,7 +126,7 @@ public final class Store: ObservableObject { DispatchQueue.main.async { self.actorName = authResponse.actorName } try await TunnelManager.shared.saveSettings(settings) - try await TunnelManager.shared.saveActorName(authResponse.actorName) + try await TunnelManager.shared.saveAuthResponse(authResponse) // Bring the tunnel up and send it a token to start TunnelManager.shared.start(token: authResponse.token) @@ -163,7 +163,7 @@ public final class Store: ObservableObject { try await TunnelManager.shared.saveSettings(newSettings) DispatchQueue.main.async { self.settings = newSettings } } catch { - Log.error("\(#function): \(error)") + Log.error(error) } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift index 7253aec62..ff3ce6d6b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift @@ -21,7 +21,7 @@ final class GrantVPNViewModel: ObservableObject { do { try await store.createVPNProfile() } catch { - Log.error("\(#function): \(error)") + Log.error(error) } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index cae9d3b7a..a2d8190ce 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -293,7 +293,7 @@ public final class MenuBar: NSObject, ObservableObject { do { try await model.store.createVPNProfile() } catch { - Log.error("\(#function): \(error)") + Log.error(error) } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 1fb1138f4..2ea4a9307 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -10,6 +10,13 @@ import SwiftUI enum SettingsViewError: Error { case logFolderIsUnavailable + + var localizedDescription: String { + switch self { + case .logFolderIsUnavailable: + return "Log folder is unavailable." + } + } } @MainActor @@ -47,7 +54,7 @@ public final class SettingsViewModel: ObservableObject { do { try await store.save(settings) } catch { - Log.error("Error saving settings to tunnel store: \(error)") + Log.error(error) } } } @@ -67,8 +74,6 @@ public final class SettingsViewModel: ObservableObject { Log.log("\(#function)") guard let logFilesFolderURL = SharedAccess.logFolderURL else { - Log.error("\(#function): Log folder is unavailable") - return "Unknown" } @@ -90,7 +95,7 @@ public final class SettingsViewModel: ObservableObject { return byteCountFormatter.string(fromByteCount: Int64(totalSize)) } catch { - Log.error("\(#function): \(error)") + Log.error(error) return "Unknown" } @@ -111,6 +116,17 @@ public final class SettingsViewModel: ObservableObject { } extension FileManager { + enum FileManagerError: Error { + case invalidURL(URL, Error) + + var localizedDescription: String { + switch self { + case .invalidURL(let url, let error): + return "Unable to get resource value for '\(url)': \(error)" + } + } + } + func forEachFileUnder( _ dirURL: URL, including resourceKeys: Set, @@ -135,7 +151,7 @@ extension FileManager { let resourceValues = try url.resourceValues(forKeys: resourceKeys) handler(url, resourceValues) } catch { - Log.error("Unable to get resource value for '\(url)': \(error)") + Log.error(FileManagerError.invalidURL(url, error)) } } } @@ -598,7 +614,7 @@ public struct SettingsView: View { window.contentViewController?.presentingViewController?.dismiss(self) } } catch { - Log.error("\(#function): \(error)") + Log.error(error) let alert = NSAlert() alert.messageText = "Error exporting logs: \(error.localizedDescription)" diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift index 61627b91f..05d5d49ca 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift @@ -11,6 +11,17 @@ import UserNotifications import Cocoa class UpdateChecker { + enum UpdateError: Error { + case invalidVersion(String) + + var localizedDescription: String { + switch self { + case .invalidVersion(let version): + return "Invalid version: \(version)" + } + } + } + private var timer: Timer? private let notificationAdapter: NotificationAdapter = NotificationAdapter() private let versionCheckUrl: URL = URL(string: "https://www.firezone.dev/api/releases")! @@ -36,12 +47,13 @@ class UpdateChecker { guard let self = self else { return } if let error = error { - Log.error("Error fetching version manifest: \(error)") + Log.error(error) return } guard let versionInfo = VersionInfo.from(data: data) else { - Log.error("No data or failed to decode data") + let attemptedVersion = String(data: data ?? Data(), encoding: .utf8) ?? "" + Log.error(UpdateError.invalidVersion(attemptedVersion)) return } @@ -89,9 +101,9 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate { notificationCenter.delegate = self notificationCenter.requestAuthorization(options: [.sound, .badge, .alert]) { _, error in - if let error = error { - Log.error("Failed to request authorization for notifications: \(error)") - } + if let error = error { + Log.error(error) + } } } @@ -114,7 +126,7 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate { UNUserNotificationCenter.current().add(request) { error in if let error = error { - Log.error("\(#function): Error requesting notification: \(error)") + Log.error(error) } } diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index e106ac752..d38300f09 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -8,16 +8,25 @@ import Foundation import NetworkExtension import OSLog -public enum AdapterError: Error { +enum AdapterError: Error { /// Failure to perform an operation in such state. - case invalidState + case invalidState(AdapterState) /// connlib failed to start case connlibConnectError(String) + + var localizedDescription: String { + switch self { + case .invalidState(let state): + return "Adapter is in an invalid state: \(state)" + case .connlibConnectError(let error): + return "connlib failed to start: \(error)" + } + } } /// Enum representing internal state of the adapter -private enum AdapterState: CustomStringConvertible { +enum AdapterState: CustomStringConvertible { case tunnelStarted(session: WrappedSession) case tunnelStopped @@ -118,15 +127,11 @@ class Adapter { public func start() async throws { Log.log("Adapter.start") guard case .tunnelStopped = self.state else { - throw AdapterError.invalidState + throw AdapterError.invalidState(self.state) } callbackHandler.delegate = self - if connlibLogFolderPath.isEmpty { - Log.error("Cannot get shared log folder for connlib") - } - Log.log("Adapter.start: Starting connlib") do { let jsonEncoder = JSONEncoder() @@ -377,8 +382,7 @@ extension Adapter: CallbackHandlerDelegate { networkSettings.dnsAddresses = dnsAddresses networkSettings.apply() case .tunnelStopped: - Log.error( - "\(#function): Unexpected state: \(self.state)") + Log.error(AdapterError.invalidState(self.state)) } } } diff --git a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift index 9b7b9866f..3f03fd6b1 100644 --- a/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift +++ b/swift/apple/FirezoneNetworkExtension/NetworkSettings.swift @@ -53,9 +53,7 @@ class NetworkSettings { packetTunnelProvider?.setTunnelNetworkSettings(tunnelNetworkSettings) { error in if let error = error { - Log.error( - "\(#function): Error occurred while applying network settings! Error: \(error.localizedDescription)" - ) + Log.error(error) } completionHandler?() diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index f4570d313..2c7c58540 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -24,6 +24,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var logExportState: LogExportState = .idle + override init() { + // Initialize Telemetry as early as possible + Telemetry.start() + + super.init() + } + override func startTunnel( options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void @@ -38,7 +45,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // The tunnel can come up without the app having been launched first, so // initialize the id here too. - try await FirezoneId.createIfMissing() + let id = try await FirezoneId.createIfMissing() + + // Hydrate the telemetry userId with our firezone id + Telemetry.setFirezoneId(id.uuid.uuidString) let passedToken = options?["token"] as? String let keychainToken = try await Token.load() @@ -62,6 +72,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Reconfigure our Telemetry environment now that we know the API URL + Telemetry.setEnvironmentOrClose(apiURL) + guard let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? .providerConfiguration as? [String: String], @@ -73,6 +86,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Hydrate telemetry account slug + Telemetry.setAccountSlug( + providerConfiguration[TunnelManagerKeys.accountSlug] + ) + let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[TunnelManagerKeys.internetResourceEnabled]?.data(using: .utf8) { (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false } else { @@ -90,7 +108,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // `connected`. completionHandler(nil) } catch { - Log.error("\(#function): Error! \(error)") + Log.error(error) completionHandler(error) } } @@ -116,7 +134,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { to: SharedAccess.providerStopReasonURL, atomically: true, encoding: .utf8) } catch { Log.error( - "\(#function): Couldn't write provider stop reason to file. Notification won't work.") + SharedAccess.Error.unableToWriteToFile( + SharedAccess.providerStopReasonURL, + error + ) + ) } #if os(iOS) // iOS notifications should be shown from the tunnel process @@ -155,32 +177,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { getLogFolderSize(completionHandler) case .exportLogs: Task { - guard let completionHandler - else { - Log.error( - "\(#function): Need a completion handler to export logs." - ) - - return - } - - exportLogs(completionHandler) + exportLogs(completionHandler!) } case .consumeStopReason: Task { - guard let completionHandler - else { - Log.error( - "\(#function): Need a completion handler to consumeStopReason." - ) - - return - } - - consumeStopReason(completionHandler) + consumeStopReason(completionHandler!) } } - } func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) { @@ -188,7 +191,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { try Log.clear(in: SharedAccess.logFolderURL) } catch { - Log.error("Error clearing logs: \(error)") + Log.error(error) } completionHandler?(nil) @@ -217,7 +220,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let chunk = try tunnelLogArchive.readChunk() completionHandler(chunk) } catch { - Log.error("\(#function): error reading chunk: \(error)") + Log.error(error) completionHandler(nil) } @@ -232,7 +235,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard let logFolderURL = SharedAccess.logFolderURL, let logFolderPath = FilePath(logFolderURL) else { - Log.error("\(#function): log folder not available") completionHandler(nil) return @@ -243,7 +245,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { try tunnelLogArchive.archive() } catch { - Log.error("\(#function): error archiving logs: \(error)") + Log.error(error) completionHandler(nil) return @@ -255,20 +257,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func consumeStopReason(_ completionHandler: (Data?) -> Void) { - do { - let data = try Data( - contentsOf: SharedAccess.providerStopReasonURL - ) - - try? FileManager.default - .removeItem(at: SharedAccess.providerStopReasonURL) - - completionHandler(data) - } catch { - Log.error("\(#function): error reading stop reason: \(error)") - + guard let data = try? Data(contentsOf: SharedAccess.providerStopReasonURL) + else { completionHandler(nil) + + return } + + try? FileManager.default + .removeItem(at: SharedAccess.providerStopReasonURL) + + completionHandler(data) } } diff --git a/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift b/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift index a8b12feb0..1de6d25f0 100644 --- a/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift +++ b/swift/apple/FirezoneNetworkExtension/SystemConfigurationResolvers.swift @@ -10,6 +10,19 @@ import Foundation import SystemConfiguration class SystemConfigurationResolvers { + enum SystemConfigurationError: Error { + case failedToCreateDynamicStore + case unableToRetrieveNetworkServices + + var localizedDescription: String { + switch self { + case .failedToCreateDynamicStore: + return "Failed to create dynamic store" + case .unableToRetrieveNetworkServices: + return "Unable to retrieve network services" + } + } + } private var dynamicStore: SCDynamicStore? // Arbitrary name for the connection to the store @@ -18,7 +31,7 @@ class SystemConfigurationResolvers { init() { guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil) else { - Log.error("\(#function): Failed to create dynamic store") + Log.error(SystemConfigurationError.failedToCreateDynamicStore) self.dynamicStore = nil return } @@ -47,7 +60,7 @@ class SystemConfigurationResolvers { let interfaceSearchKey = "Setup:/Network/Service/.*/Interface" as CFString guard let services = SCDynamicStoreCopyKeyList(dynamicStore, interfaceSearchKey) as? [String] else { - Log.error("\(#function): Unable to retrieve network services") + Log.error(SystemConfigurationError.unableToRetrieveNetworkServices) return [] } diff --git a/swift/apple/PrivacyInfo.xcprivacy b/swift/apple/PrivacyInfo.xcprivacy index f1ee383a5..6eabe583a 100644 --- a/swift/apple/PrivacyInfo.xcprivacy +++ b/swift/apple/PrivacyInfo.xcprivacy @@ -2,8 +2,50 @@ + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePerformanceData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryFileTimestamp @@ -12,6 +54,33 @@ 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + +