Files
firezone/swift/apple/FirezoneNetworkExtension/TunnelLogArchive.swift
Thomas Eizinger f211c9d46a feat(apple): use .zip for logs (#9536)
This PR replaces the use of Apple Archive with an API that allows us to
zip the log file contents. This API doesn't handle symlinks well so we
move the symlink out of the way before making the zip. The symlink is
then moved back after the process is completed. Any errors in this
process are ignored as the symlink itself is not a critical component of
Firezone.

The zip compression is marginally less efficient than the Apple Archive.
Instead of compressing ~2GB of logs to 11.8 MB we now get an archive of
12.4 MB. Considering how much easier zip files are to handle, this seems
like a fine trade-off.

<img width="774" alt="Screenshot 2025-06-16 at 00 04 52"
src="https://github.com/user-attachments/assets/8fb6bade-5308-40b9-a446-2a2c364cb621"
/>

Resolves: #7475

---------

Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
2025-06-23 22:25:57 +00:00

101 lines
2.9 KiB
Swift

//
// TunnelArchiveByteStream.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import FirezoneKit
import Foundation
import System
/// We must enable the app sandbox when distributing the macOS client in the App Store. Since the tunnel
/// process runs as root, this makes sharing log files between the app process (running as the
/// logged in user) and tunnel process (running as root) tricky. The app process can't read or write directly to
/// the tunnel's log directory, and vice-versa for the tunnel process and app log directory.
///
/// The way we overcome this is IPC. This gets tricky with exporting logs, however. We can't
/// simply read the tunnel log directory into a giant buffer as this could be too large to send over the IPC
/// channel. Instead, we write the tunnel log archive to a temp file and then chunk it over with IPC.
///
/// Since the IPC channel is unidirectional from app -> tunnel, we use a simple data format to pass chunks
/// of this archive file from the tunnel back to the app, including a boolean `done` to indicate when the
/// archive is sent and the app should close its associated file.
///
/// Currently this limit is set to 1 MB (chosen somewhat arbitrarily based on limited information found on the
/// web), but can be easily enlarged in the future to reduce the number of IPC calls required to consume
/// the entire archive.
class TunnelLogArchive {
enum ArchiveError: Error {
case unableToWriteArchive
case unableToReadArchive
}
let chunkSize = 1024 * 1024 // 1 MiB
let encoder = PropertyListEncoder()
let archiveURL = FileManager
.default
.temporaryDirectory
.appendingPathComponent("logs.zip")
var offset: UInt64 = 0
var fileHandle: FileHandle?
var source: URL
init(source: URL) {
self.source = source
}
deinit {
cleanup()
}
func archive() throws {
try? FileManager.default.removeItem(at: self.archiveURL)
try ZipService.createZip(
source: source,
to: self.archiveURL
)
}
func readChunk() throws -> (Data, Bool) {
if self.fileHandle == nil {
// Open the file for reading
try self.fileHandle = FileHandle(forReadingFrom: archiveURL)
}
guard let fileHandle = self.fileHandle
else {
throw ArchiveError.unableToReadArchive
}
// Read archive at offset up to chunkSize bytes
try fileHandle.seek(toOffset: self.offset)
guard let data = try fileHandle.read(upToCount: chunkSize)
else {
throw ArchiveError.unableToReadArchive
}
self.offset += UInt64(data.count)
let chunk = LogChunk(
done: data.count < chunkSize, // we're done if we read less than chunkSize
data: data
)
if chunk.done {
cleanup()
}
return try (encoder.encode(chunk), chunk.done)
}
func cleanup() {
try? self.fileHandle?.close()
try? FileManager.default.removeItem(at: archiveURL)
self.offset = 0
self.fileHandle = nil
}
}