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:
Jamil
2024-12-18 12:36:02 -08:00
committed by GitHub
parent a1cf409af3
commit 56c592a27b
5 changed files with 170 additions and 53 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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?

View File

@@ -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
}

View File

@@ -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)
}
}
}