mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 02:18:47 +00:00
* macOS 13 and below has a known bug that prevents us from saving the token on the system keychain. To avoid Sentry noise, we ignore this specific error and continue to log other errors that aren't an exact match. * Relatedly, if we try to start the tunnel and a token is not found, it's not necessarily an error. This happens when the user signs out and then tries to activate the VPN from system settings, for example. --------- Signed-off-by: Jamil <jamilbk@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
385 lines
11 KiB
Swift
385 lines
11 KiB
Swift
//
|
|
// PacketTunnelProvider.swift
|
|
// (c) 2024 Firezone, Inc.
|
|
// LICENSE: Apache-2.0
|
|
//
|
|
|
|
import FirezoneKit
|
|
import NetworkExtension
|
|
import System
|
|
import os
|
|
|
|
enum PacketTunnelProviderError: Error {
|
|
case tunnelConfigurationIsInvalid
|
|
case firezoneIdIsInvalid
|
|
case tokenNotFoundInKeychain
|
|
}
|
|
|
|
class PacketTunnelProvider: NEPacketTunnelProvider {
|
|
private var adapter: Adapter?
|
|
|
|
enum LogExportState {
|
|
case inProgress(TunnelLogArchive)
|
|
case idle
|
|
}
|
|
|
|
private var getLogFolderSizeTask: Task<Void, Never>?
|
|
|
|
private var logExportState: LogExportState = .idle
|
|
private var tunnelConfiguration: TunnelConfiguration?
|
|
private let defaults = UserDefaults.standard
|
|
|
|
override init() {
|
|
// Initialize Telemetry as early as possible
|
|
Telemetry.start()
|
|
|
|
super.init()
|
|
|
|
// Log version information immediately on startup
|
|
let version =
|
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
|
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
|
|
let bundleId = Bundle.main.bundleIdentifier ?? "unknown"
|
|
Log.info(
|
|
"NetworkExtension starting - Version: \(version), Build: \(build), Bundle ID: \(bundleId)")
|
|
|
|
migrateFirezoneId()
|
|
self.tunnelConfiguration = TunnelConfiguration.tryLoad()
|
|
}
|
|
|
|
deinit {
|
|
getLogFolderSizeTask?.cancel()
|
|
}
|
|
|
|
override func startTunnel(
|
|
options: [String: NSObject]?,
|
|
completionHandler: @escaping @Sendable (Error?) -> Void
|
|
) {
|
|
// Dummy start to attach a utun for cleanup later
|
|
if options?["cycleStart"] as? Bool == true {
|
|
Log.info("Cycle start requested - extension awakened and temporarily starting tunnel")
|
|
return completionHandler(nil)
|
|
}
|
|
|
|
// Log version on actual tunnel start
|
|
let version =
|
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
|
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
|
|
Log.info("Starting tunnel - Version: \(version), Build: \(build)")
|
|
|
|
// If the tunnel starts up before the GUI after an upgrade crossing the 1.4.15 version boundary,
|
|
// the old system settings-based config will still be present and the new configuration will be empty.
|
|
// So handle that edge case gracefully.
|
|
let legacyConfiguration = VPNConfigurationManager.legacyConfiguration(
|
|
protocolConfiguration: protocolConfiguration as? NETunnelProviderProtocol
|
|
)
|
|
|
|
do {
|
|
// If we don't have a token, we can't continue.
|
|
guard let token = loadAndSaveToken(from: options)
|
|
else {
|
|
return completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain)
|
|
}
|
|
|
|
// Try to save the token back to the Keychain but continue if we can't
|
|
handleTokenSave(token)
|
|
|
|
// The firezone id should be initialized by now
|
|
guard let id = UserDefaults.standard.string(forKey: "firezoneId")
|
|
else {
|
|
throw PacketTunnelProviderError.firezoneIdIsInvalid
|
|
}
|
|
|
|
guard let apiURL = legacyConfiguration?["apiURL"] ?? tunnelConfiguration?.apiURL,
|
|
let logFilter = legacyConfiguration?["logFilter"] ?? tunnelConfiguration?.logFilter,
|
|
let accountSlug = legacyConfiguration?["accountSlug"] ?? tunnelConfiguration?.accountSlug
|
|
else {
|
|
throw PacketTunnelProviderError.tunnelConfigurationIsInvalid
|
|
}
|
|
|
|
// Configure telemetry
|
|
Telemetry.setEnvironmentOrClose(apiURL)
|
|
Task { await Telemetry.setAccountSlug(accountSlug) }
|
|
|
|
let enabled = legacyConfiguration?["internetResourceEnabled"]
|
|
let internetResourceEnabled =
|
|
enabled != nil ? enabled == "true" : (tunnelConfiguration?.internetResourceEnabled ?? false)
|
|
|
|
// Create the adapter with all configuration
|
|
let adapter = Adapter(
|
|
apiURL: apiURL,
|
|
token: token,
|
|
deviceId: id,
|
|
logFilter: logFilter,
|
|
accountSlug: accountSlug,
|
|
internetResourceEnabled: internetResourceEnabled,
|
|
packetTunnelProvider: self,
|
|
startCompletionHandler: completionHandler
|
|
)
|
|
|
|
// Start the adapter
|
|
try adapter.start()
|
|
|
|
self.adapter = adapter
|
|
|
|
} catch {
|
|
Log.error(error)
|
|
completionHandler(error)
|
|
}
|
|
}
|
|
|
|
override func wake() {
|
|
adapter?.reset(reason: "awoke from sleep")
|
|
}
|
|
|
|
// This can be called by the system, or initiated by connlib.
|
|
// When called by the system, we call Adapter.stop() from here.
|
|
// When initiated by connlib, we've already called stop() there.
|
|
override func stopTunnel(
|
|
with reason: NEProviderStopReason, completionHandler: @escaping @Sendable () -> Void
|
|
) {
|
|
Log.log("stopTunnel: Reason: \(reason)")
|
|
|
|
// handles both connlib-initiated and user-initiated stops
|
|
adapter?.stop()
|
|
completionHandler()
|
|
}
|
|
|
|
// It would be helpful to be able to encapsulate Errors here. To do that
|
|
// we need to update ProviderMessage to encode/decode Result to and from Data.
|
|
// TODO: Move to a more abstract IPC protocol
|
|
override func handleAppMessage(
|
|
_ message: Data, completionHandler: (@Sendable (Data?) -> Void)? = nil
|
|
) {
|
|
do {
|
|
let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message)
|
|
|
|
switch providerMessage {
|
|
|
|
case .setConfiguration(let tunnelConfiguration):
|
|
tunnelConfiguration.save()
|
|
self.tunnelConfiguration = tunnelConfiguration
|
|
|
|
self.adapter?.setInternetResourceEnabled(tunnelConfiguration.internetResourceEnabled)
|
|
completionHandler?(nil)
|
|
|
|
case .signOut:
|
|
do { try Token.delete() } catch { Log.error(error) }
|
|
completionHandler?(nil)
|
|
case .getResourceList(let hash):
|
|
guard let adapter = adapter
|
|
else {
|
|
Log.warning("Adapter is nil")
|
|
completionHandler?(nil)
|
|
|
|
return
|
|
}
|
|
|
|
// Use hash comparison to only return resources if they've changed
|
|
adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceData in
|
|
completionHandler?(resourceData)
|
|
}
|
|
case .clearLogs:
|
|
clearLogs(completionHandler)
|
|
case .getLogFolderSize:
|
|
getLogFolderSize(completionHandler)
|
|
case .exportLogs:
|
|
exportLogs(completionHandler!)
|
|
}
|
|
} catch {
|
|
Log.error(error)
|
|
completionHandler?(nil)
|
|
}
|
|
}
|
|
|
|
func loadAndSaveToken(from options: [String: NSObject]?) -> Token? {
|
|
let passedToken = options?["token"] as? String
|
|
|
|
// Try to load saved token from Keychain, continuing if Keychain is
|
|
// unavailable.
|
|
let keychainToken = {
|
|
do { return try Token.load() } catch { Log.error(error) }
|
|
|
|
return nil
|
|
}()
|
|
|
|
return Token(passedToken) ?? keychainToken
|
|
}
|
|
|
|
func clearLogs(_ completionHandler: (@Sendable (Data?) -> Void)? = nil) {
|
|
do {
|
|
try Log.clear(in: SharedAccess.logFolderURL)
|
|
} catch {
|
|
Log.error(error)
|
|
}
|
|
|
|
completionHandler?(nil)
|
|
}
|
|
|
|
func getLogFolderSize(_ completionHandler: (@Sendable (Data?) -> Void)? = nil) {
|
|
guard let logFolderURL = SharedAccess.logFolderURL
|
|
else {
|
|
completionHandler?(nil)
|
|
|
|
return
|
|
}
|
|
|
|
let task = Task {
|
|
let size = await Log.size(of: logFolderURL)
|
|
let data = withUnsafeBytes(of: size) { Data($0) }
|
|
|
|
// Call completion handler with data if not cancelled
|
|
guard !Task.isCancelled else { return }
|
|
completionHandler?(data)
|
|
}
|
|
getLogFolderSizeTask = task
|
|
}
|
|
|
|
func exportLogs(_ completionHandler: @escaping @Sendable (Data?) -> Void) {
|
|
func sendChunk(_ tunnelLogArchive: TunnelLogArchive) {
|
|
do {
|
|
let (chunk, done) = try tunnelLogArchive.readChunk()
|
|
|
|
if done {
|
|
self.logExportState = .idle
|
|
}
|
|
|
|
completionHandler(chunk)
|
|
} catch {
|
|
Log.error(error)
|
|
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
switch self.logExportState {
|
|
|
|
case .inProgress(let tunnelLogArchive):
|
|
sendChunk(tunnelLogArchive)
|
|
|
|
case .idle:
|
|
guard let logFolderURL = SharedAccess.logFolderURL,
|
|
let cacheFolderURL = SharedAccess.cacheFolderURL,
|
|
let connlibLogFolderURL = SharedAccess.connlibLogFolderURL
|
|
else {
|
|
completionHandler(nil)
|
|
|
|
return
|
|
}
|
|
|
|
let tunnelLogArchive = TunnelLogArchive(source: logFolderURL)
|
|
|
|
let latestSymlink = connlibLogFolderURL.appendingPathComponent("latest")
|
|
let tempSymlink = cacheFolderURL.appendingPathComponent(
|
|
"latest")
|
|
|
|
do {
|
|
// Move the `latest` symlink out of the way before creating the archive.
|
|
// Apple's implementation of zip appears to not be able to handle symlinks well
|
|
let _ = try? FileManager.default.moveItem(at: latestSymlink, to: tempSymlink)
|
|
defer {
|
|
let _ = try? FileManager.default.moveItem(at: tempSymlink, to: latestSymlink)
|
|
}
|
|
|
|
try tunnelLogArchive.archive()
|
|
} catch {
|
|
Log.error(error)
|
|
completionHandler(nil)
|
|
|
|
return
|
|
}
|
|
|
|
self.logExportState = .inProgress(tunnelLogArchive)
|
|
sendChunk(tunnelLogArchive)
|
|
}
|
|
}
|
|
|
|
// Firezone ID migration. Can be removed once most clients migrate past 1.4.15.
|
|
private func migrateFirezoneId() {
|
|
let filename = "firezone-id"
|
|
let key = "firezoneId"
|
|
|
|
// 1. Try to load from file, deleting it
|
|
if let containerURL = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId),
|
|
let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent(filename))
|
|
{
|
|
defaults.set(idFromFile, forKey: key)
|
|
try? FileManager.default.removeItem(at: containerURL.appendingPathComponent(filename))
|
|
return
|
|
}
|
|
|
|
// 2. Try to load from dict
|
|
if defaults.string(forKey: key) != nil {
|
|
return
|
|
}
|
|
|
|
// 3. Generate and save new one
|
|
defaults.set(UUID().uuidString, forKey: key)
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func handleTokenSave(_ token: Token) {
|
|
do {
|
|
try token.save()
|
|
} catch let error as KeychainError {
|
|
// macOS 13 and below have a bug that raises an error when a root proc (such as our system extension) tries
|
|
// to add an item to the system keychain. We can safely ignore this.
|
|
if #unavailable(macOS 14.0), case .appleSecError("SecItemAdd", 100001) = error {
|
|
// ignore
|
|
} else {
|
|
Log.error(error)
|
|
}
|
|
} catch {
|
|
Log.error(error)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if os(iOS)
|
|
private func handleTokenSave(_ token: Token) {
|
|
do { try token.save() } catch { Log.error(error) }
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Increase usefulness of TunnelConfiguration now that we're over the IPC barrier
|
|
extension TunnelConfiguration {
|
|
func save() {
|
|
let key = "configurationCache"
|
|
|
|
let dict: [String: Any] = [
|
|
"apiURL": apiURL,
|
|
"logFilter": logFilter,
|
|
"accountSlug": accountSlug,
|
|
"internetResourceEnabled": internetResourceEnabled,
|
|
]
|
|
|
|
UserDefaults.standard.set(dict, forKey: key)
|
|
}
|
|
|
|
static func tryLoad() -> TunnelConfiguration? {
|
|
let key = "configurationCache"
|
|
|
|
guard let dict = UserDefaults.standard.dictionary(forKey: key)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
guard let apiURL = dict["apiURL"] as? String,
|
|
let logFilter = dict["logFilter"] as? String,
|
|
let accountSlug = dict["accountSlug"] as? String,
|
|
let internetResourceEnabled = dict["internetResourceEnabled"] as? Bool
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return TunnelConfiguration(
|
|
apiURL: apiURL,
|
|
accountSlug: accountSlug,
|
|
logFilter: logFilter,
|
|
internetResourceEnabled: internetResourceEnabled
|
|
)
|
|
}
|
|
}
|