From 56c592a27b2844ae71b20b86ffe355fa0e1e6952 Mon Sep 17 00:00:00 2001 From: Jamil Date: Wed, 18 Dec 2024 12:36:02 -0800 Subject: [PATCH] fix(apple): Make clear logs and log size functions work across the IPC boundary (#7467) The macOS client starting in 1.4.0 uses a system extension for its network extension package type. This process runs as root and does not have access to the app's Group Container folder for reading / writing log files directly, and vice-versa. This means the tunnel now writes its logs to a separate directory as the GUI app process. Since the logging functions of clearing logs, calculating their size, and exporting them assume all the logs are in the same directory, we need to introduce IPC handlers to ensure the GUI app can conveniently still perform these functions when initiated by the user. We already use the Network Extension API `sendProviderMessage` as our IPC mechanism for adhoc, bi-directional communication through the tunnel, so we add more handlers to this mechanism to support the logging functions summarized above. In this PR we only fix the log size calculation and clear log functionality. Exporting logs is more involved and will be implemented in another dedicated PR. --- .../Sources/FirezoneKit/Helpers/Log.swift | 42 ++++++++ .../FirezoneKit/Managers/TunnelManager.swift | 50 ++++++++++ .../Sources/FirezoneKit/Stores/Store.swift | 2 +- .../FirezoneKit/Views/SettingsView.swift | 95 +++++++++---------- .../PacketTunnelProvider.swift | 34 +++++++ 5 files changed, 170 insertions(+), 53 deletions(-) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift index 70116833d..c919b66f9 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift @@ -52,6 +52,48 @@ public final class Log { self.logger.error("\(message, privacy: .public)") logWriter?.write(severity: .error, message: message) } + + // Returns the size in bytes of the provided directory, calculated by summing + // the size of its contents recursively. + public static func size(of directory: URL) async -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + + func sizeOfFile(at url: URL, with resourceValues: URLResourceValues) -> Int64 { + guard resourceValues.isRegularFile == true else { return 0 } + return Int64(resourceValues.totalFileAllocatedSize ?? resourceValues.totalFileSize ?? 0) + } + + // Tally size of each log file in parallel + await withTaskGroup(of: Int64.self) { taskGroup in + fileManager.forEachFileUnder( + directory, + including: [ + .totalFileAllocatedSizeKey, + .totalFileSizeKey, + .isRegularFileKey, + ] + ) { url, resourceValues in + taskGroup.addTask { + return sizeOfFile(at: url, with: resourceValues) + } + } + + for await size in taskGroup { + totalSize += size + } + } + + return totalSize + } + + // Clears the contents of the provided directory. + public static func clear(in directory: URL?) throws { + guard let directory = directory + else { return } + + try FileManager.default.removeItem(at: directory) + } } private final class LogWriter { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index a19f88eaf..4419fc1f3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -13,6 +13,7 @@ import NetworkExtension enum TunnelManagerError: Error { case cannotSaveIfMissing + case decodeIPCDataFailed } public enum TunnelManagerKeys { @@ -27,6 +28,8 @@ public enum TunnelMessage: Codable { case getResourceList(Data) case signOut case internetResourceEnabled(Bool) + case clearLogs + case getLogFolderSize enum CodingKeys: String, CodingKey { case type @@ -37,6 +40,8 @@ public enum TunnelMessage: Codable { case getResourceList case signOut case internetResourceEnabled + case clearLogs + case getLogFolderSize } public init(from decoder: Decoder) throws { @@ -51,6 +56,10 @@ public enum TunnelMessage: Codable { self = .getResourceList(value) case .signOut: self = .signOut + case .clearLogs: + self = .clearLogs + case .getLogFolderSize: + self = .getLogFolderSize } } public func encode(to encoder: Encoder) throws { @@ -64,6 +73,10 @@ public enum TunnelMessage: Codable { try container.encode(value, forKey: .value) case .signOut: try container.encode(MessageType.signOut, forKey: .type) + case .clearLogs: + try container.encode(MessageType.clearLogs, forKey: .type) + case .getLogFolderSize: + try container.encode(MessageType.getLogFolderSize, forKey: .type) } } } @@ -277,6 +290,43 @@ public class TunnelManager { } } + func clearLogs() async throws { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(TunnelMessage.clearLogs) + ) { _ in continuation.resume() } + } catch { + continuation.resume(throwing: error) + } + } + } + + func getLogFolderSize() async throws -> Int64 { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(TunnelMessage.getLogFolderSize) + ) { data in + + guard let data = data + else { + continuation + .resume(throwing: TunnelManagerError.decodeIPCDataFailed) + + return + } + + data.withUnsafeBytes { rawBuffer in + continuation.resume(returning: rawBuffer.load(as: Int64.self)) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + private func session() -> NETunnelProviderSession { guard let manager = manager, let session = manager.connection as? NETunnelProviderSession diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index afeb20bf2..99d157b41 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -28,7 +28,7 @@ public final class Store: ObservableObject { // we could periodically update it if we need to. @Published private(set) var decision: UNAuthorizationStatus - private let tunnelManager: TunnelManager + public let tunnelManager: TunnelManager private var sessionNotification: SessionNotification private var cancellables: Set = [] private var resourcesTimer: Timer? diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index a573853e2..8ec5db8ee 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -52,70 +52,61 @@ public final class SettingsViewModel: ObservableObject { } } - func calculateLogDirSize() -> String? { + // Calculates the total size of our logs by summing the size of the + // app, tunnel, and connlib log directories. + // + // On iOS, SharedAccess.logFolderURL is a single folder that contains all + // three directories, but on macOS, the app log directory lives in a different + // Group Container than tunnel and connlib directories, so we use IPC to make + // a call to sum both the tunnel and connlib directories. + // + // Unfortunately the IPC method doesn't work on iOS because the tunnel process + // is not started on demand, so the IPC calls hang. Thus, we use separate code + // paths for iOS and macOS. + func calculateLogDirSize() async -> String { Log.app.log("\(#function)") guard let logFilesFolderURL = SharedAccess.logFolderURL else { Log.app.error("\(#function): Log folder is unavailable") - return nil + + return "Unknown" } - let fileManager = FileManager.default + let logFolderSize = await Log.size(of: logFilesFolderURL) - var totalSize = 0 - fileManager.forEachFileUnder( - logFilesFolderURL, - including: [ - .totalFileAllocatedSizeKey, - .totalFileSizeKey, - .isRegularFileKey, - ] - ) { url, resourceValues in - if resourceValues.isRegularFile ?? false { - totalSize += (resourceValues.totalFileAllocatedSize ?? resourceValues.totalFileSize ?? 0) - } + do { +#if os(macOS) + let providerLogFolderSize = try await store.tunnelManager.getLogFolderSize() + let totalSize = logFolderSize + providerLogFolderSize +#else + let totalSize = logFolderSize +#endif + + let byteCountFormatter = ByteCountFormatter() + byteCountFormatter.countStyle = .file + byteCountFormatter.allowsNonnumericFormatting = false + byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB, .usePB] + + return byteCountFormatter.string(fromByteCount: Int64(totalSize)) + + } catch { + Log.app.error("\(#function): \(error)") + + return "Unknown" } - - if Task.isCancelled { - return nil - } - - let byteCountFormatter = ByteCountFormatter() - byteCountFormatter.countStyle = .file - byteCountFormatter.allowsNonnumericFormatting = false - byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB, .usePB] - return byteCountFormatter.string(fromByteCount: Int64(totalSize)) } - func clearAllLogs() throws { + // On iOS, all the logs are stored in one directory. + // On macOS, we need to clear logs from the app process, then call over IPC + // to clear the provider's log directory. + func clearAllLogs() async throws { Log.app.log("\(#function)") - guard let logFilesFolderURL = SharedAccess.logFolderURL else { - Log.app.error("\(#function): Log folder is unavailable") - return - } + try Log.clear(in: SharedAccess.logFolderURL) - let fileManager = FileManager.default - var unremovedFilesCount = 0 - fileManager.forEachFileUnder( - logFilesFolderURL, - including: [ - .isRegularFileKey - ] - ) { url, resourceValues in - if resourceValues.isRegularFile ?? false { - do { - try fileManager.removeItem(at: url) - } catch { - unremovedFilesCount += 1 - Log.app.error("Unable to remove '\(url)': \(error)") - } - } - } - - if unremovedFilesCount > 0 { - Log.app.log("\(#function): Unable to remove \(unremovedFilesCount) files") - } +#if os(macOS) + try await store.tunnelManager.clearLogs() +#endif } } @@ -623,7 +614,7 @@ public struct SettingsView: View { self.calculateLogSizeTask = Task.detached(priority: .userInitiated) { let calculatedLogsSize = await model.calculateLogDirSize() await MainActor.run { - self.calculatedLogsSize = calculatedLogsSize ?? "Unknown" + self.calculatedLogsSize = calculatedLogsSize self.isCalculatingLogsSize = false self.calculateLogSizeTask = nil } diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 6ca33e236..7b67aa90c 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -124,6 +124,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler() } + // TODO: It would be helpful to be able to encapsulate Errors here. To do that + // we need to update TunnelMessage to encode/decode Result to and from Data. override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return } @@ -139,6 +141,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider { resourceListJSON in completionHandler?(resourceListJSON?.data(using: .utf8)) } + case .clearLogs: + clearLogs(completionHandler) + case .getLogFolderSize: + getLogFolderSize(completionHandler) + } + } + + func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) { + Task { + do { + try Log.clear(in: SharedAccess.logFolderURL) + } catch { + Log.tunnel.error("Error clearing logs: \(error)") + } + + completionHandler?(nil) + } + } + + func getLogFolderSize(_ completionHandler: ((Data?) -> Void)? = nil) { + guard let logFolderURL = SharedAccess.logFolderURL + else { + completionHandler?(nil) + + return + } + + Task { + let size = await Log.size(of: logFolderURL) + let data = withUnsafeBytes(of: size) { Data($0) } + + completionHandler?(data) } } }