mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AnyCancellable> = []
|
||||
private var resourcesTimer: Timer?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user