mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
refactor(apple): Split IPC and VPN config into separate classes (#8279)
The current `VPNConfigurationManager` class is too large and handles 3 separate things: - Encoding IPC messages - Sending IPC messages - Loading/storing the VPN configuration With this PR, these are split out into: - ProviderMessage class - IPCClient class - VPNConfigurationManager class These are then use directly from our `Store` in order to load their state upon `init()`, set the relevant properties, and thus views are updated accordingly. A couple minor bugs are fixed as well as part of the refactor. ### Tested: macOS - [x] Sign in - [x] Sign out - [x] Yanking the VPN configuration while signed in / out - [x] Yanking the system extension while signed in / out - [x] Denying the VPN configuration - [x] Denying Notifications - [x] Denying System Extension ### Tested: iOS - [x] Sign in - [x] Sign out - [x] Yanking the VPN configuration while signed in / out - [x] Yanking the system extension while signed in / out - [x] Denying the VPN configuration - [x] Denying Notifications --------- Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
@@ -76,6 +76,7 @@ struct FirezoneApp: App {
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
if let store {
|
||||
menuBar = MenuBar(store: store)
|
||||
AppView.subscribeToGlobalEvents(store: store)
|
||||
}
|
||||
|
||||
// SwiftUI will show the first window group, so close it on launch
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//
|
||||
// IPCClient.swift
|
||||
// (c) 2024 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
class IPCClient {
|
||||
enum Error: Swift.Error {
|
||||
case invalidNotification
|
||||
case decodeIPCDataFailed
|
||||
case noIPCData
|
||||
case invalidStatus(NEVPNStatus)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidNotification:
|
||||
return "NEVPNStatusDidChange notification doesn't seem to be valid."
|
||||
case .decodeIPCDataFailed:
|
||||
return "Decoding IPC data failed."
|
||||
case .noIPCData:
|
||||
return "No IPC data returned from the XPC connection!"
|
||||
case .invalidStatus(let status):
|
||||
return "The IPC operation couldn't complete because the VPN status is \(status)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IPC only makes sense if there's a valid session. Session in this case refers to the `connection` field of
|
||||
// the NETunnelProviderManager instance.
|
||||
let session: NETunnelProviderSession
|
||||
|
||||
// Track the "version" of the resource list so we can more efficiently
|
||||
// retrieve it from the Provider
|
||||
var resourceListHash = Data()
|
||||
|
||||
// Cache resources on this side of the IPC barrier so we can
|
||||
// return them to callers when they haven't changed.
|
||||
var resourcesListCache: ResourceList = ResourceList.loading
|
||||
|
||||
init(session: NETunnelProviderSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// Encoder used to send messages to the tunnel
|
||||
let encoder = {
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
|
||||
return encoder
|
||||
}()
|
||||
|
||||
func start(token: String? = nil) throws {
|
||||
var options: [String: NSObject] = [:]
|
||||
|
||||
// Pass token if provided
|
||||
if let token = token {
|
||||
options.merge(["token": token as NSObject]) { _, new in new }
|
||||
}
|
||||
|
||||
// Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this
|
||||
// persisted to the app side container URL.
|
||||
if let id = FirezoneId.load(.pre140) {
|
||||
options.merge(["id": id as NSObject]) { _, new in new }
|
||||
}
|
||||
|
||||
try session().startTunnel(options: options)
|
||||
}
|
||||
|
||||
func signOut() throws {
|
||||
try session([.connected, .connecting, .reasserting]).stopTunnel()
|
||||
try session().sendProviderMessage(encoder.encode(ProviderMessage.signOut))
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
try session([.connected, .connecting, .reasserting]).stopTunnel()
|
||||
}
|
||||
|
||||
func toggleInternetResource(enabled: Bool) throws {
|
||||
try session([.connected]).sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.internetResourceEnabled(enabled)))
|
||||
}
|
||||
|
||||
func fetchResources() async throws -> ResourceList {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
// Request list of resources from the provider. We send the hash of the resource list we already have.
|
||||
// If it differs, we'll get the full list in the callback. If not, we'll get nil.
|
||||
try session([.connected]).sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.getResourceList(resourceListHash))) { data in
|
||||
guard let data = data
|
||||
else {
|
||||
// No data returned; Resources haven't changed
|
||||
continuation.resume(returning: self.resourcesListCache)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Save hash to compare against
|
||||
self.resourceListHash = Data(SHA256.hash(data: data))
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
do {
|
||||
let decoded = try decoder.decode([Resource].self, from: data)
|
||||
self.resourcesListCache = ResourceList.loaded(decoded)
|
||||
|
||||
continuation.resume(returning: self.resourcesListCache)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogs() async throws {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session().sendProviderMessage(encoder.encode(ProviderMessage.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(ProviderMessage.getLogFolderSize)
|
||||
) { data in
|
||||
|
||||
guard let data = data
|
||||
else {
|
||||
continuation
|
||||
.resume(throwing: Error.noIPCData)
|
||||
|
||||
return
|
||||
}
|
||||
data.withUnsafeBytes { rawBuffer in
|
||||
continuation.resume(returning: rawBuffer.load(as: Int64.self))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call this with a closure that will append each chunk to a buffer
|
||||
// of some sort, like a file. The completed buffer is a valid Apple Archive
|
||||
// in AAR format.
|
||||
func exportLogs(
|
||||
appender: @escaping (LogChunk) -> Void,
|
||||
errorHandler: @escaping (Error) -> Void
|
||||
) {
|
||||
let decoder = PropertyListDecoder()
|
||||
|
||||
func loop() {
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.exportLogs)
|
||||
) { data in
|
||||
guard let data = data
|
||||
else {
|
||||
errorHandler(Error.noIPCData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
guard let chunk = try? decoder.decode(
|
||||
LogChunk.self, from: data
|
||||
)
|
||||
else {
|
||||
errorHandler(Error.decodeIPCDataFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appender(chunk)
|
||||
|
||||
if !chunk.done {
|
||||
// Continue
|
||||
loop()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start exporting
|
||||
loop()
|
||||
}
|
||||
|
||||
func consumeStopReason() async throws -> NEProviderStopReason? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.consumeStopReason)
|
||||
) { data in
|
||||
|
||||
guard let data = data,
|
||||
let reason = String(data: data, encoding: .utf8),
|
||||
let rawValue = Int(reason)
|
||||
else {
|
||||
continuation.resume(returning: nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(returning: NEProviderStopReason(rawValue: rawValue))
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to system notifications about our VPN status changing
|
||||
// and let our handler know about them.
|
||||
func subscribeToVPNStatusUpdates(handler: @escaping @MainActor (NEVPNStatus) async throws -> Void) {
|
||||
Task {
|
||||
for await notification in NotificationCenter.default.notifications(named: .NEVPNStatusDidChange) {
|
||||
guard let session = notification.object as? NETunnelProviderSession
|
||||
else {
|
||||
Log.error(Error.invalidNotification)
|
||||
return
|
||||
}
|
||||
|
||||
if session.status == .disconnected {
|
||||
// Reset resource list
|
||||
resourceListHash = Data()
|
||||
resourcesListCache = ResourceList.loading
|
||||
}
|
||||
|
||||
do { try await handler(session.status) } catch { Log.error(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sessionStatus() -> NEVPNStatus {
|
||||
return session.status
|
||||
}
|
||||
|
||||
private func session(_ requiredStatuses: Set<NEVPNStatus> = []) throws -> NETunnelProviderSession {
|
||||
if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) {
|
||||
return session
|
||||
}
|
||||
|
||||
throw Error.invalidStatus(session.status)
|
||||
}
|
||||
}
|
||||
@@ -105,8 +105,8 @@ public final class Log {
|
||||
// because these happen often due to code signing requirements.
|
||||
private static func shouldCaptureError(_ err: Error) -> Bool {
|
||||
#if DEBUG
|
||||
if let err = err as? VPNConfigurationManagerError,
|
||||
case VPNConfigurationManagerError.noIPCData = err {
|
||||
if let err = err as? IPCClient.Error,
|
||||
case IPCClient.Error.noIPCData = err {
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -28,7 +28,7 @@ enum LogExporter {
|
||||
|
||||
static func export(
|
||||
to archiveURL: URL,
|
||||
with vpnConfigurationManager: VPNConfigurationManager
|
||||
with ipcClient: IPCClient
|
||||
) async throws {
|
||||
guard let logFolderURL = SharedAccess.logFolderURL
|
||||
else {
|
||||
@@ -53,7 +53,7 @@ enum LogExporter {
|
||||
|
||||
// 3. Await tunnel log export from tunnel process
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
vpnConfigurationManager.exportLogs(
|
||||
ipcClient.exportLogs(
|
||||
appender: { chunk in
|
||||
do {
|
||||
// Append each chunk to the archive
|
||||
|
||||
@@ -7,151 +7,41 @@
|
||||
// Abstracts the nitty gritty of loading and saving to our
|
||||
// VPN configuration in system preferences.
|
||||
|
||||
// TODO: Refactor to fix file length
|
||||
// swiftlint:disable file_length
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
enum VPNConfigurationManagerError: Error {
|
||||
case managerNotInitialized
|
||||
case cannotLoad
|
||||
case decodeIPCDataFailed
|
||||
case invalidNotification
|
||||
case noIPCData
|
||||
case invalidStatus(NEVPNStatus)
|
||||
case savedProtocolConfigurationIsInvalid
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .managerNotInitialized:
|
||||
return "Manager doesn't seem initialized."
|
||||
case .decodeIPCDataFailed:
|
||||
return "Decoding IPC data failed."
|
||||
case .invalidNotification:
|
||||
return "NEVPNStatusDidChange notification doesn't seem to be valid."
|
||||
case .cannotLoad:
|
||||
return "Could not load VPN configurations!"
|
||||
case .noIPCData:
|
||||
return "No IPC data returned from the XPC connection!"
|
||||
case .invalidStatus(let status):
|
||||
return "The IPC operation couldn't complete because the VPN status is \(status)."
|
||||
return "NETunnelProviderManager is not yet initialized. Race condition?"
|
||||
case .savedProtocolConfigurationIsInvalid:
|
||||
return "Saved protocol configuration is invalid. Check types?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum VPNConfigurationManagerKeys {
|
||||
static let actorName = "actorName"
|
||||
static let authBaseURL = "authBaseURL"
|
||||
static let apiURL = "apiURL"
|
||||
public static let accountSlug = "accountSlug"
|
||||
public static let logFilter = "logFilter"
|
||||
public static let internetResourceEnabled = "internetResourceEnabled"
|
||||
}
|
||||
|
||||
public enum TunnelMessage: Codable {
|
||||
case getResourceList(Data)
|
||||
case signOut
|
||||
case internetResourceEnabled(Bool)
|
||||
case clearLogs
|
||||
case getLogFolderSize
|
||||
case exportLogs
|
||||
case consumeStopReason
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case value
|
||||
}
|
||||
|
||||
enum MessageType: String, Codable {
|
||||
case getResourceList
|
||||
case signOut
|
||||
case internetResourceEnabled
|
||||
case clearLogs
|
||||
case getLogFolderSize
|
||||
case exportLogs
|
||||
case consumeStopReason
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(MessageType.self, forKey: .type)
|
||||
switch type {
|
||||
case .internetResourceEnabled:
|
||||
let value = try container.decode(Bool.self, forKey: .value)
|
||||
self = .internetResourceEnabled(value)
|
||||
case .getResourceList:
|
||||
let value = try container.decode(Data.self, forKey: .value)
|
||||
self = .getResourceList(value)
|
||||
case .signOut:
|
||||
self = .signOut
|
||||
case .clearLogs:
|
||||
self = .clearLogs
|
||||
case .getLogFolderSize:
|
||||
self = .getLogFolderSize
|
||||
case .exportLogs:
|
||||
self = .exportLogs
|
||||
case .consumeStopReason:
|
||||
self = .consumeStopReason
|
||||
}
|
||||
}
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .internetResourceEnabled(let value):
|
||||
try container.encode(MessageType.internetResourceEnabled, forKey: .type)
|
||||
try container.encode(value, forKey: .value)
|
||||
case .getResourceList(let value):
|
||||
try container.encode(MessageType.getResourceList, forKey: .type)
|
||||
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)
|
||||
case .exportLogs:
|
||||
try container.encode(MessageType.exportLogs, forKey: .type)
|
||||
case .consumeStopReason:
|
||||
try container.encode(MessageType.consumeStopReason, forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor this to remove the lint ignore
|
||||
// swiftlint:disable:next type_body_length
|
||||
public class VPNConfigurationManager {
|
||||
|
||||
// Connect status updates with our listeners
|
||||
private var tunnelObservingTasks: [Task<Void, Never>] = []
|
||||
|
||||
// Track the "version" of the resource list so we can more efficiently
|
||||
// retrieve it from the Provider
|
||||
private var resourceListHash = Data()
|
||||
|
||||
// Cache resources on this side of the IPC barrier so we can
|
||||
// return them to callers when they haven't changed.
|
||||
private var resourcesListCache: ResourceList = ResourceList.loading
|
||||
public enum Keys {
|
||||
static let actorName = "actorName"
|
||||
static let authBaseURL = "authBaseURL"
|
||||
static let apiURL = "apiURL"
|
||||
public static let accountSlug = "accountSlug"
|
||||
public static let logFilter = "logFilter"
|
||||
public static let internetResourceEnabled = "internetResourceEnabled"
|
||||
}
|
||||
|
||||
// Persists our tunnel settings
|
||||
private var manager: NETunnelProviderManager?
|
||||
|
||||
// Indicates if the internet resource is currently enabled
|
||||
public var internetResourceEnabled: Bool = false
|
||||
|
||||
// Encoder used to send messages to the tunnel
|
||||
private let encoder = {
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
|
||||
return encoder
|
||||
}()
|
||||
let manager: NETunnelProviderManager
|
||||
|
||||
public static let bundleIdentifier: String = "\(Bundle.main.bundleIdentifier!).network-extension"
|
||||
private let bundleDescription = "Firezone"
|
||||
static let bundleDescription = "Firezone"
|
||||
|
||||
// Initialize and save a new VPN configuration in system Preferences
|
||||
func create() async throws {
|
||||
init() async throws {
|
||||
let protocolConfiguration = NETunnelProviderProtocol()
|
||||
let manager = NETunnelProviderManager()
|
||||
let settings = Settings.defaultValue
|
||||
@@ -159,119 +49,100 @@ public class VPNConfigurationManager {
|
||||
protocolConfiguration.providerConfiguration = settings.toProviderConfiguration()
|
||||
protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier
|
||||
protocolConfiguration.serverAddress = settings.apiURL
|
||||
manager.localizedDescription = bundleDescription
|
||||
manager.localizedDescription = VPNConfigurationManager.bundleDescription
|
||||
manager.protocolConfiguration = protocolConfiguration
|
||||
|
||||
// Save the new VPN configuration to System Preferences and reload it,
|
||||
// which should update our status from nil -> disconnected.
|
||||
// If the user denied the operation, the status will be .invalid
|
||||
do {
|
||||
try await manager.saveToPreferences()
|
||||
try await manager.loadFromPreferences()
|
||||
self.manager = manager
|
||||
} catch let error as NSError {
|
||||
if error.domain == "NEVPNErrorDomain" && error.code == 5 {
|
||||
// Silence error when the user doesn't click "Allow" on the VPN
|
||||
// permission dialog
|
||||
Log.info("VPN permission was denied by the user")
|
||||
try await manager.saveToPreferences()
|
||||
try await manager.loadFromPreferences()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
func loadFromPreferences(
|
||||
vpnStateUpdateHandler: @escaping @MainActor (NEVPNStatus, Settings?, String?, NEProviderStopReason?) async -> Void
|
||||
) async throws {
|
||||
init(from manager: NETunnelProviderManager) {
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
static func load() async throws -> VPNConfigurationManager? {
|
||||
// loadAllFromPreferences() returns list of VPN configurations created by our main app's bundle ID.
|
||||
// Since our bundle ID can change (by us), find the one that's current and ignore the others.
|
||||
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
||||
|
||||
Log.log("\(#function): \(managers.count) tunnel managers found")
|
||||
for manager in managers where manager.localizedDescription == bundleDescription {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.cannotLoad
|
||||
}
|
||||
|
||||
// Update our state
|
||||
self.manager = manager
|
||||
|
||||
let settings = Settings.fromProviderConfiguration(providerConfiguration)
|
||||
let actorName = providerConfiguration[VPNConfigurationManagerKeys.actorName]
|
||||
if let internetResourceEnabled = providerConfiguration[
|
||||
VPNConfigurationManagerKeys.internetResourceEnabled
|
||||
]?.data(using: .utf8) {
|
||||
|
||||
self.internetResourceEnabled = (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabled)) ?? false
|
||||
|
||||
}
|
||||
let status = manager.connection.status
|
||||
|
||||
// Configure our Telemetry environment
|
||||
Telemetry.setEnvironmentOrClose(settings.apiURL)
|
||||
Telemetry.accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug]
|
||||
|
||||
// Share what we found with our caller
|
||||
await vpnStateUpdateHandler(status, settings, actorName, nil)
|
||||
|
||||
// Stop looking for our tunnel
|
||||
break
|
||||
return VPNConfigurationManager(from: manager)
|
||||
}
|
||||
|
||||
// If no tunnel configuration was found, update state to
|
||||
// prompt user to create one.
|
||||
if manager == nil {
|
||||
await vpnStateUpdateHandler(.invalid, nil, nil, nil)
|
||||
}
|
||||
|
||||
// Hook up status updates
|
||||
subscribeToVPNStatusUpdates(handler: vpnStateUpdateHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAuthResponse(_ authResponse: AuthResponse) async throws {
|
||||
guard let manager = manager,
|
||||
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
var providerConfiguration = protocolConfiguration.providerConfiguration
|
||||
func actorName() throws -> String? {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
|
||||
}
|
||||
|
||||
providerConfiguration[VPNConfigurationManagerKeys.actorName] = authResponse.actorName
|
||||
providerConfiguration[VPNConfigurationManagerKeys.accountSlug] = authResponse.accountSlug
|
||||
return providerConfiguration[Keys.actorName]
|
||||
}
|
||||
|
||||
func internetResourceEnabled() throws -> Bool? {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
|
||||
}
|
||||
|
||||
// TODO: Store Bool directly in VPN Configuration
|
||||
if providerConfiguration[Keys.internetResourceEnabled] == "true" {
|
||||
return true
|
||||
}
|
||||
|
||||
if providerConfiguration[Keys.internetResourceEnabled] == "false" {
|
||||
return false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func save(authResponse: AuthResponse) async throws {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
|
||||
}
|
||||
|
||||
providerConfiguration[Keys.actorName] = authResponse.actorName
|
||||
providerConfiguration[Keys.accountSlug] = authResponse.accountSlug
|
||||
|
||||
// Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure.
|
||||
Telemetry.accountSlug = providerConfiguration[Keys.accountSlug]
|
||||
|
||||
protocolConfiguration.providerConfiguration = providerConfiguration
|
||||
manager.protocolConfiguration = protocolConfiguration
|
||||
|
||||
// We always set this to true when starting the tunnel in case our tunnel
|
||||
// was disabled by the system for some reason.
|
||||
// Always set this to true when starting the tunnel in case our tunnel was disabled by the system.
|
||||
manager.isEnabled = true
|
||||
|
||||
try await manager.saveToPreferences()
|
||||
try await manager.loadFromPreferences()
|
||||
}
|
||||
|
||||
func saveSettings(_ settings: Settings) async throws {
|
||||
guard let manager = manager,
|
||||
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
func save(settings: Settings) async throws {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
|
||||
}
|
||||
|
||||
var newProviderConfiguration = settings.toProviderConfiguration()
|
||||
|
||||
// Don't clobber existing actorName
|
||||
newProviderConfiguration[VPNConfigurationManagerKeys.actorName] =
|
||||
providerConfiguration[VPNConfigurationManagerKeys.actorName]
|
||||
newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName]
|
||||
|
||||
protocolConfiguration.providerConfiguration = newProviderConfiguration
|
||||
protocolConfiguration.serverAddress = settings.apiURL
|
||||
manager.protocolConfiguration = protocolConfiguration
|
||||
|
||||
// We always set this to true when starting the tunnel in case our tunnel
|
||||
// was disabled by the system for some reason.
|
||||
manager.isEnabled = true
|
||||
|
||||
try await manager.saveToPreferences()
|
||||
@@ -281,236 +152,17 @@ public class VPNConfigurationManager {
|
||||
Telemetry.setEnvironmentOrClose(settings.apiURL)
|
||||
}
|
||||
|
||||
func start(token: String? = nil) throws {
|
||||
var options: [String: NSObject] = [:]
|
||||
|
||||
// Pass token if provided
|
||||
if let token = token {
|
||||
options.merge(["token": token as NSObject]) { _, new in new }
|
||||
}
|
||||
|
||||
// Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this
|
||||
// persisted to the app side container URL.
|
||||
if let id = FirezoneId.load(.pre140) {
|
||||
options.merge(["id": id as NSObject]) { _, new in new }
|
||||
}
|
||||
|
||||
try session().startTunnel(options: options)
|
||||
}
|
||||
|
||||
func signOut() throws {
|
||||
try session([.connected, .connecting, .reasserting]).stopTunnel()
|
||||
try session().sendProviderMessage(encoder.encode(TunnelMessage.signOut))
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
try session([.connected, .connecting, .reasserting]).stopTunnel()
|
||||
}
|
||||
|
||||
func updateInternetResourceState() throws {
|
||||
try session([.connected]).sendProviderMessage(
|
||||
encoder.encode(TunnelMessage.internetResourceEnabled(internetResourceEnabled)))
|
||||
}
|
||||
|
||||
func toggleInternetResource(enabled: Bool) throws {
|
||||
internetResourceEnabled = enabled
|
||||
try updateInternetResourceState()
|
||||
}
|
||||
|
||||
func fetchResources() async throws -> ResourceList {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
// Request list of resources from the provider. We send the hash of the resource list we already have.
|
||||
// If it differs, we'll get the full list in the callback. If not, we'll get nil.
|
||||
try session([.connected]).sendProviderMessage(
|
||||
encoder.encode(TunnelMessage.getResourceList(resourceListHash))) { data in
|
||||
|
||||
guard let data = data
|
||||
else {
|
||||
// No data returned; Resources haven't changed
|
||||
continuation.resume(returning: self.resourcesListCache)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Save hash to compare against
|
||||
self.resourceListHash = Data(SHA256.hash(data: data))
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
do {
|
||||
let decoded = try decoder.decode([Resource].self, from: data)
|
||||
self.resourcesListCache = ResourceList.loaded(decoded)
|
||||
|
||||
continuation.resume(returning: self.resourcesListCache)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: VPNConfigurationManagerError.noIPCData)
|
||||
|
||||
return
|
||||
}
|
||||
data.withUnsafeBytes { rawBuffer in
|
||||
continuation.resume(returning: rawBuffer.load(as: Int64.self))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call this with a closure that will append each chunk to a buffer
|
||||
// of some sort, like a file. The completed buffer is a valid Apple Archive
|
||||
// in AAR format.
|
||||
func exportLogs(
|
||||
appender: @escaping (LogChunk) -> Void,
|
||||
errorHandler: @escaping (VPNConfigurationManagerError) -> Void
|
||||
) {
|
||||
let decoder = PropertyListDecoder()
|
||||
|
||||
func loop() {
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
encoder.encode(TunnelMessage.exportLogs)
|
||||
) { data in
|
||||
guard let data = data
|
||||
else {
|
||||
errorHandler(VPNConfigurationManagerError.noIPCData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
guard let chunk = try? decoder.decode(
|
||||
LogChunk.self, from: data
|
||||
)
|
||||
else {
|
||||
errorHandler(VPNConfigurationManagerError.decodeIPCDataFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appender(chunk)
|
||||
|
||||
if !chunk.done {
|
||||
// Continue
|
||||
loop()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start exporting
|
||||
loop()
|
||||
}
|
||||
|
||||
func consumeStopReason() async throws -> NEProviderStopReason? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
encoder.encode(TunnelMessage.consumeStopReason)
|
||||
) { data in
|
||||
|
||||
guard let data = data,
|
||||
let reason = String(data: data, encoding: .utf8),
|
||||
let rawValue = Int(reason)
|
||||
else {
|
||||
continuation.resume(returning: nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(returning: NEProviderStopReason(rawValue: rawValue))
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func session(_ requiredStatuses: Set<NEVPNStatus> = []) throws -> NETunnelProviderSession {
|
||||
guard let session = manager?.connection as? NETunnelProviderSession
|
||||
func settings() throws -> Settings {
|
||||
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
|
||||
}
|
||||
|
||||
if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) {
|
||||
return session
|
||||
}
|
||||
|
||||
throw VPNConfigurationManagerError.invalidStatus(session.status)
|
||||
return Settings.fromProviderConfiguration(providerConfiguration)
|
||||
}
|
||||
|
||||
// Subscribe to system notifications about our VPN status changing
|
||||
// and let our handler know about them.
|
||||
private func subscribeToVPNStatusUpdates(
|
||||
handler: @escaping @MainActor (NEVPNStatus, Settings?, String?, NEProviderStopReason?
|
||||
) async -> Void) {
|
||||
Log.log("\(#function)")
|
||||
|
||||
for task in tunnelObservingTasks {
|
||||
task.cancel()
|
||||
}
|
||||
tunnelObservingTasks.removeAll()
|
||||
|
||||
tunnelObservingTasks.append(
|
||||
Task {
|
||||
for await notification in NotificationCenter.default.notifications(
|
||||
named: .NEVPNStatusDidChange
|
||||
) {
|
||||
guard let session = notification.object as? NETunnelProviderSession
|
||||
else {
|
||||
Log.error(VPNConfigurationManagerError.invalidNotification)
|
||||
return
|
||||
}
|
||||
|
||||
var reason: NEProviderStopReason?
|
||||
|
||||
if session.status == .disconnected {
|
||||
// Reset resource list
|
||||
resourceListHash = Data()
|
||||
resourcesListCache = ResourceList.loading
|
||||
|
||||
// Attempt to consume the last stopped reason
|
||||
do { reason = try await consumeStopReason() } catch { Log.error(error) }
|
||||
}
|
||||
|
||||
await handler(session.status, nil, nil, reason)
|
||||
}
|
||||
}
|
||||
)
|
||||
func session() -> NETunnelProviderSession? {
|
||||
return manager.connection as? NETunnelProviderSession
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// ProviderMessage.swift
|
||||
// (c) 2024 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
// Encodes / Decodes messages to the provider service.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ProviderMessage: Codable {
|
||||
case getResourceList(Data)
|
||||
case signOut
|
||||
case internetResourceEnabled(Bool)
|
||||
case clearLogs
|
||||
case getLogFolderSize
|
||||
case exportLogs
|
||||
case consumeStopReason
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case value
|
||||
}
|
||||
|
||||
enum MessageType: String, Codable {
|
||||
case getResourceList
|
||||
case signOut
|
||||
case internetResourceEnabled
|
||||
case clearLogs
|
||||
case getLogFolderSize
|
||||
case exportLogs
|
||||
case consumeStopReason
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(MessageType.self, forKey: .type)
|
||||
switch type {
|
||||
case .internetResourceEnabled:
|
||||
let value = try container.decode(Bool.self, forKey: .value)
|
||||
self = .internetResourceEnabled(value)
|
||||
case .getResourceList:
|
||||
let value = try container.decode(Data.self, forKey: .value)
|
||||
self = .getResourceList(value)
|
||||
case .signOut:
|
||||
self = .signOut
|
||||
case .clearLogs:
|
||||
self = .clearLogs
|
||||
case .getLogFolderSize:
|
||||
self = .getLogFolderSize
|
||||
case .exportLogs:
|
||||
self = .exportLogs
|
||||
case .consumeStopReason:
|
||||
self = .consumeStopReason
|
||||
}
|
||||
}
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .internetResourceEnabled(let value):
|
||||
try container.encode(MessageType.internetResourceEnabled, forKey: .type)
|
||||
try container.encode(value, forKey: .value)
|
||||
case .getResourceList(let value):
|
||||
try container.encode(MessageType.getResourceList, forKey: .type)
|
||||
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)
|
||||
case .exportLogs:
|
||||
try container.encode(MessageType.exportLogs, forKey: .type)
|
||||
case .consumeStopReason:
|
||||
try container.encode(MessageType.consumeStopReason, forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,14 @@ struct Settings: Equatable {
|
||||
static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings {
|
||||
if let providerConfiguration = providerConfiguration as? [String: String] {
|
||||
return Settings(
|
||||
authBaseURL: providerConfiguration[VPNConfigurationManagerKeys.authBaseURL]
|
||||
authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL]
|
||||
?? Settings.defaultValue.authBaseURL,
|
||||
apiURL: providerConfiguration[VPNConfigurationManagerKeys.apiURL]
|
||||
apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL]
|
||||
?? Settings.defaultValue.apiURL,
|
||||
logFilter: providerConfiguration[VPNConfigurationManagerKeys.logFilter]
|
||||
logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter]
|
||||
?? Settings.defaultValue.logFilter,
|
||||
internetResourceEnabled: getInternetResourceEnabled(
|
||||
internetResourceEnabled: providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled])
|
||||
internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled])
|
||||
)
|
||||
} else {
|
||||
return Settings.defaultValue
|
||||
@@ -62,10 +62,10 @@ struct Settings: Equatable {
|
||||
}
|
||||
|
||||
return [
|
||||
VPNConfigurationManagerKeys.authBaseURL: authBaseURL,
|
||||
VPNConfigurationManagerKeys.apiURL: apiURL,
|
||||
VPNConfigurationManagerKeys.logFilter: logFilter,
|
||||
VPNConfigurationManagerKeys.internetResourceEnabled: string
|
||||
VPNConfigurationManager.Keys.authBaseURL: authBaseURL,
|
||||
VPNConfigurationManager.Keys.apiURL: apiURL,
|
||||
VPNConfigurationManager.Keys.logFilter: logFilter,
|
||||
VPNConfigurationManager.Keys.internetResourceEnabled: string
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public final class Store: ObservableObject {
|
||||
@Published private(set) var actorName: String?
|
||||
|
||||
// Make our tunnel configuration convenient for SettingsView to consume
|
||||
@Published var settings: Settings
|
||||
@Published private(set) var settings = Settings.defaultValue
|
||||
|
||||
// Enacapsulate Tunnel status here to make it easier for other components
|
||||
// to observe
|
||||
@@ -28,54 +28,61 @@ public final class Store: ObservableObject {
|
||||
|
||||
@Published private(set) var decision: UNAuthorizationStatus?
|
||||
|
||||
@Published private(set) var internetResourceEnabled: Bool?
|
||||
|
||||
#if os(macOS)
|
||||
// Track whether our system extension has been installed (macOS)
|
||||
@Published private(set) var systemExtensionStatus: SystemExtensionStatus?
|
||||
#endif
|
||||
|
||||
let vpnConfigurationManager: VPNConfigurationManager
|
||||
private var sessionNotification: SessionNotification
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
let sessionNotification = SessionNotification()
|
||||
|
||||
private var resourcesTimer: Timer?
|
||||
private var resourceUpdateTask: Task<Void, Never>?
|
||||
|
||||
private var vpnConfigurationManager: VPNConfigurationManager?
|
||||
|
||||
public init() {
|
||||
// Initialize all stored properties
|
||||
self.settings = Settings.defaultValue
|
||||
self.sessionNotification = SessionNotification()
|
||||
self.vpnConfigurationManager = VPNConfigurationManager()
|
||||
|
||||
self.sessionNotification.signInHandler = {
|
||||
Task {
|
||||
do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) }
|
||||
}
|
||||
}
|
||||
|
||||
// Load our state from the system. Based on what's loaded, we may need to ask the user for permission for things.
|
||||
initNotifications()
|
||||
initSystemExtension()
|
||||
initVPNConfiguration()
|
||||
}
|
||||
|
||||
func initNotifications() {
|
||||
Task {
|
||||
// Load user's decision whether to allow / disallow notifications
|
||||
self.decision = await self.sessionNotification.loadAuthorizationStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Load VPN configuration and system extension status
|
||||
do {
|
||||
try await self.bindToVPNConfigurationUpdates()
|
||||
let vpnConfigurationStatus = self.status
|
||||
|
||||
func initSystemExtension() {
|
||||
#if os(macOS)
|
||||
let systemExtensionStatus = try await self.checkedSystemExtensionStatus()
|
||||
|
||||
if systemExtensionStatus != .installed
|
||||
|| vpnConfigurationStatus == .invalid {
|
||||
|
||||
// Show the main Window if VPN permission needs to be granted
|
||||
AppView.WindowDefinition.main.openWindow()
|
||||
} else {
|
||||
AppView.WindowDefinition.main.window()?.close()
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
self.systemExtensionStatus = try await self.checkSystemExtensionStatus()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if vpnConfigurationStatus == .disconnected {
|
||||
|
||||
// Try to connect on start
|
||||
try self.vpnConfigurationManager.start()
|
||||
func initVPNConfiguration() {
|
||||
Task {
|
||||
do {
|
||||
// Try to load existing configuration
|
||||
if let manager = try await VPNConfigurationManager.load() {
|
||||
self.vpnConfigurationManager = manager
|
||||
self.settings = try manager.settings()
|
||||
try await setupTunnelObservers(autoStart: true)
|
||||
} else {
|
||||
status = .invalid
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
@@ -83,56 +90,65 @@ public final class Store: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public func internetResourceEnabled() -> Bool {
|
||||
self.vpnConfigurationManager.internetResourceEnabled
|
||||
func setupTunnelObservers(autoStart: Bool = false) async throws {
|
||||
let statusChangeHandler: (NEVPNStatus) async throws -> Void = { [weak self] status in
|
||||
try await self?.handleStatusChange(newStatus: status)
|
||||
}
|
||||
|
||||
try ipcClient().subscribeToVPNStatusUpdates(handler: statusChangeHandler)
|
||||
|
||||
if autoStart && status == .disconnected {
|
||||
// Try to connect on start
|
||||
try ipcClient().start()
|
||||
}
|
||||
|
||||
try await handleStatusChange(newStatus: ipcClient().sessionStatus())
|
||||
}
|
||||
|
||||
func bindToVPNConfigurationUpdates() async throws {
|
||||
// Load our existing VPN configuration and set an update handler
|
||||
try await self.vpnConfigurationManager.loadFromPreferences(
|
||||
vpnStateUpdateHandler: { @MainActor [weak self] status, settings, actorName, stopReason in
|
||||
guard let self else { return }
|
||||
func handleStatusChange(newStatus: NEVPNStatus) async throws {
|
||||
status = newStatus
|
||||
|
||||
self.status = status
|
||||
if status == .connected {
|
||||
// Load saved actorName
|
||||
actorName = try? manager().actorName()
|
||||
|
||||
if let settings {
|
||||
self.settings = settings
|
||||
}
|
||||
// Load saved internet resource status
|
||||
internetResourceEnabled = try? manager().internetResourceEnabled()
|
||||
|
||||
if let actorName {
|
||||
self.actorName = actorName
|
||||
}
|
||||
|
||||
if status == .connected {
|
||||
self.beginUpdatingResources { resourceList in
|
||||
self.resourceList = resourceList
|
||||
}
|
||||
}
|
||||
|
||||
if status == .disconnected {
|
||||
self.endUpdatingResources()
|
||||
self.resourceList = ResourceList.loading
|
||||
}
|
||||
// Load Resources
|
||||
beginUpdatingResources()
|
||||
} else {
|
||||
endUpdatingResources()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification
|
||||
// from the tunnel process, because the UI process is not guaranteed to be alive.
|
||||
if status == .disconnected,
|
||||
stopReason == .authenticationCanceled {
|
||||
// On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification
|
||||
// from the tunnel process, because the UI process is not guaranteed to be alive.
|
||||
if status == .disconnected {
|
||||
do {
|
||||
let reason = try await ipcClient().consumeStopReason()
|
||||
if reason == .authenticationCanceled {
|
||||
await self.sessionNotification.showSignedOutAlertmacOS()
|
||||
}
|
||||
#endif
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// When this happens, it's because either our VPN configuration or System Extension (or both) were removed.
|
||||
// So load the system extension status again to determine which view to load.
|
||||
if status == .invalid {
|
||||
self.systemExtensionStatus = try await checkSystemExtensionStatus()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func checkedSystemExtensionStatus() async throws -> SystemExtensionStatus {
|
||||
func checkSystemExtensionStatus() async throws -> SystemExtensionStatus {
|
||||
let checker = SystemExtensionManager()
|
||||
|
||||
let status =
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
|
||||
|
||||
checker.checkStatus(
|
||||
identifier: VPNConfigurationManager.bundleIdentifier,
|
||||
continuation: continuation
|
||||
@@ -145,8 +161,6 @@ public final class Store: ObservableObject {
|
||||
try await installSystemExtension()
|
||||
}
|
||||
|
||||
self.systemExtensionStatus = status
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -157,7 +171,6 @@ public final class Store: ObservableObject {
|
||||
// See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers
|
||||
self.systemExtensionStatus =
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
|
||||
|
||||
installer.installSystemExtension(
|
||||
identifier: VPNConfigurationManager.bundleIdentifier,
|
||||
continuation: continuation
|
||||
@@ -166,12 +179,29 @@ public final class Store: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
func grantVPNPermission() async throws {
|
||||
func installVPNConfiguration() async throws {
|
||||
// Create a new VPN configuration in system settings.
|
||||
try await self.vpnConfigurationManager.create()
|
||||
self.vpnConfigurationManager = try await VPNConfigurationManager()
|
||||
|
||||
// Reload our state
|
||||
try await bindToVPNConfigurationUpdates()
|
||||
try await setupTunnelObservers()
|
||||
}
|
||||
|
||||
func ipcClient() throws -> IPCClient {
|
||||
guard let session = try manager().session()
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
|
||||
return IPCClient(session: session)
|
||||
}
|
||||
|
||||
func manager() throws -> VPNConfigurationManager {
|
||||
guard let vpnConfigurationManager
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
|
||||
return vpnConfigurationManager
|
||||
}
|
||||
|
||||
func grantNotifications() async throws {
|
||||
@@ -182,34 +212,48 @@ public final class Store: ObservableObject {
|
||||
return URL(string: settings.authBaseURL)
|
||||
}
|
||||
|
||||
private func start(token: String? = nil) throws {
|
||||
try self.vpnConfigurationManager.start(token: token)
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
try self.vpnConfigurationManager.stop()
|
||||
try ipcClient().stop()
|
||||
}
|
||||
|
||||
func signIn(authResponse: AuthResponse) async throws {
|
||||
// Save actorName
|
||||
self.actorName = authResponse.actorName
|
||||
|
||||
try await self.vpnConfigurationManager.saveSettings(settings)
|
||||
try await self.vpnConfigurationManager.saveAuthResponse(authResponse)
|
||||
try await manager().save(authResponse: authResponse)
|
||||
|
||||
// Bring the tunnel up and send it a token to start
|
||||
try self.vpnConfigurationManager.start(token: authResponse.token)
|
||||
try ipcClient().start(token: authResponse.token)
|
||||
}
|
||||
|
||||
func signOut() throws {
|
||||
try self.vpnConfigurationManager.signOut()
|
||||
try ipcClient().signOut()
|
||||
}
|
||||
|
||||
func clearLogs() async throws {
|
||||
try await ipcClient().clearLogs()
|
||||
}
|
||||
|
||||
func saveSettings(_ newSettings: Settings) async throws {
|
||||
try await manager().save(settings: newSettings)
|
||||
self.settings = newSettings
|
||||
}
|
||||
|
||||
func toggleInternetResource() async throws {
|
||||
internetResourceEnabled = !(internetResourceEnabled ?? false)
|
||||
settings.internetResourceEnabled = internetResourceEnabled
|
||||
|
||||
try ipcClient().toggleInternetResource(enabled: internetResourceEnabled == true)
|
||||
try await manager().save(settings: settings)
|
||||
}
|
||||
|
||||
private func start(token: String? = nil) throws {
|
||||
try ipcClient().start(token: token)
|
||||
}
|
||||
|
||||
// Network Extensions don't have a 2-way binding up to the GUI process,
|
||||
// so we need to periodically ask the tunnel process for them.
|
||||
func beginUpdatingResources(callback: @escaping @MainActor (ResourceList) -> Void) {
|
||||
Log.log("\(#function)")
|
||||
|
||||
private func beginUpdatingResources() {
|
||||
if self.resourcesTimer != nil {
|
||||
// Prevent duplicate timer scheduling. This will happen if the system sends us two .connected status updates
|
||||
// in a row, which can happen occasionally.
|
||||
@@ -219,11 +263,17 @@ public final class Store: ObservableObject {
|
||||
// Define the Timer's closure
|
||||
let updateResources: @Sendable (Timer) -> Void = { _ in
|
||||
Task {
|
||||
do {
|
||||
let resources = try await self.vpnConfigurationManager.fetchResources()
|
||||
await callback(resources)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await MainActor.run {
|
||||
self.resourceUpdateTask?.cancel()
|
||||
self.resourceUpdateTask = Task {
|
||||
if !Task.isCancelled {
|
||||
do {
|
||||
self.resourceList = try await self.ipcClient().fetchResources()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,20 +290,10 @@ public final class Store: ObservableObject {
|
||||
updateResources(timer)
|
||||
}
|
||||
|
||||
func endUpdatingResources() {
|
||||
private func endUpdatingResources() {
|
||||
resourceUpdateTask?.cancel()
|
||||
resourcesTimer?.invalidate()
|
||||
resourcesTimer = nil
|
||||
}
|
||||
|
||||
func save(_ newSettings: Settings) async throws {
|
||||
try await self.vpnConfigurationManager.saveSettings(newSettings)
|
||||
self.settings = newSettings
|
||||
}
|
||||
|
||||
func toggleInternetResource(enabled: Bool) async throws {
|
||||
try self.vpnConfigurationManager.toggleInternetResource(enabled: enabled)
|
||||
var newSettings = settings
|
||||
newSettings.internetResourceEnabled = self.vpnConfigurationManager.internetResourceEnabled
|
||||
try await save(newSettings)
|
||||
resourceList = ResourceList.loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,29 @@ public struct AppView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
#if os(macOS)
|
||||
// This is a static function because the Environment Object is not present at initialization time when we want to
|
||||
// subscribe the AppView to certain Store properties to control the main window lifecycle which SwiftUI doesn't
|
||||
// handle.
|
||||
private static var cancellables: Set<AnyCancellable> = []
|
||||
public static func subscribeToGlobalEvents(store: Store) {
|
||||
store.$status
|
||||
.combineLatest(store.$systemExtensionStatus)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main) // Prevents flurry of windows from opening
|
||||
.sink(receiveValue: { status, systemExtensionStatus in
|
||||
// Open window in case permissions are revoked
|
||||
if status == .invalid || systemExtensionStatus != .installed {
|
||||
WindowDefinition.main.openWindow()
|
||||
}
|
||||
|
||||
// Close window upon launch for day-to-day use
|
||||
if status != .invalid && systemExtensionStatus == .installed && FirezoneId.load(.pre140) != nil {
|
||||
WindowDefinition.main.window()?.close()
|
||||
}
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
public enum WindowDefinition: String, CaseIterable {
|
||||
case main
|
||||
case settings
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
#if os(macOS)
|
||||
import SystemExtensions
|
||||
#endif
|
||||
|
||||
struct GrantVPNView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@EnvironmentObject var errorHandler: GlobalErrorHandler
|
||||
@@ -36,7 +40,7 @@ struct GrantVPNView: View {
|
||||
.imageScale(.large)
|
||||
Spacer()
|
||||
Button("Grant VPN Permission") {
|
||||
grantVPNPermission()
|
||||
installVPNConfiguration()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -108,7 +112,7 @@ struct GrantVPNView: View {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
grantVPNPermission()
|
||||
installVPNConfiguration()
|
||||
},
|
||||
label: {
|
||||
Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled")
|
||||
@@ -144,10 +148,21 @@ struct GrantVPNView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func grantVPNPermission() {
|
||||
func installVPNConfiguration() {
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
try await store.installVPNConfiguration()
|
||||
} catch let error as NSError {
|
||||
if error.domain == "NEVPNErrorDomain" && error.code == 5 {
|
||||
// Warn when the user doesn't click "Allow" on the VPN dialog
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Permission required."
|
||||
alert.informativeText =
|
||||
"Firezone requires permission to install VPN configurations. Without it, all functionality will be disabled."
|
||||
_ = alert.runModal()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
@@ -161,10 +176,10 @@ struct GrantVPNView: View {
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func grantVPNPermission() {
|
||||
func installVPNConfiguration() {
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
try await store.installVPNConfiguration()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
var statusItem: NSStatusItem
|
||||
var lastShownFavorites: [Resource] = []
|
||||
var lastShownOthers: [Resource] = []
|
||||
var wasInternetResourceEnabled: Bool = false
|
||||
var wasInternetResourceEnabled: Bool?
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
var updateChecker: UpdateChecker = UpdateChecker()
|
||||
var updateMenuDisplayed: Bool = false
|
||||
@@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
lastShownFavorites = newFavorites
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled()
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled
|
||||
}
|
||||
|
||||
func populateOtherResourcesMenu(_ newOthers: [Resource]) {
|
||||
@@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
lastShownOthers = newOthers
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled()
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled
|
||||
}
|
||||
|
||||
func updateStatusItemIcon() {
|
||||
@@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
return wasInternetResourceEnabled != store.internetResourceEnabled()
|
||||
return wasInternetResourceEnabled != store.internetResourceEnabled
|
||||
}
|
||||
|
||||
func refreshUpdateItem() {
|
||||
@@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func internetResourceTitle(resource: Resource) -> String {
|
||||
let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
|
||||
return status + " " + resource.name
|
||||
}
|
||||
@@ -526,7 +526,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func internetResourceToggleTitle() -> String {
|
||||
store.internetResourceEnabled() ? "Disable this resource" : "Enable this resource"
|
||||
store.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource"
|
||||
}
|
||||
|
||||
// TODO: Refactor this when refactoring for macOS 13
|
||||
@@ -700,7 +700,17 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
// the system extension here too just in case. It's a no-op if already
|
||||
// installed.
|
||||
try await store.installSystemExtension()
|
||||
try await store.grantVPNPermission()
|
||||
try await store.installVPNConfiguration()
|
||||
} catch let error as NSError {
|
||||
if error.domain == "NEVPNErrorDomain" && error.code == 5 {
|
||||
// Warn when the user doesn't click "Allow" on the VPN dialog
|
||||
let alert = NSAlert()
|
||||
alert.messageText =
|
||||
"Firezone requires permission to install VPN configurations. Without it, all functionality will be disabled."
|
||||
_ = alert.runModal()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
@@ -756,7 +766,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
@objc func internetResourceToggle(_ sender: NSMenuItem) {
|
||||
Task {
|
||||
do {
|
||||
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled())
|
||||
try await store.toggleInternetResource()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ struct ToggleInternetResourceButton: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
private func toggleResourceEnabledText() -> String {
|
||||
if store.internetResourceEnabled() {
|
||||
if store.internetResourceEnabled == true {
|
||||
"Disable this resource"
|
||||
} else {
|
||||
"Enable this resource"
|
||||
@@ -247,7 +247,7 @@ struct ToggleInternetResourceButton: View {
|
||||
action: {
|
||||
Task {
|
||||
do {
|
||||
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled())
|
||||
try await store.toggleInternetResource()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ struct ResourceSection: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
private func internetResourceTitle(resource: Resource) -> String {
|
||||
let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
|
||||
return status + " " + resource.name
|
||||
}
|
||||
|
||||
@@ -522,13 +522,13 @@ public struct SettingsView: View {
|
||||
do {
|
||||
try await LogExporter.export(
|
||||
to: destinationURL,
|
||||
with: store.vpnConfigurationManager
|
||||
with: store.ipcClient()
|
||||
)
|
||||
|
||||
window.contentViewController?.presentingViewController?.dismiss(self)
|
||||
} catch {
|
||||
if let error = error as? VPNConfigurationManagerError,
|
||||
case VPNConfigurationManagerError.noIPCData = error {
|
||||
if let error = error as? IPCClient.Error,
|
||||
case IPCClient.Error.noIPCData = error {
|
||||
Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?")
|
||||
} else {
|
||||
Log.error(error)
|
||||
@@ -584,7 +584,7 @@ public struct SettingsView: View {
|
||||
try self.store.signOut()
|
||||
}
|
||||
|
||||
try await store.save(settings)
|
||||
try await store.saveSettings(settings)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
@@ -613,7 +613,7 @@ public struct SettingsView: View {
|
||||
|
||||
do {
|
||||
#if os(macOS)
|
||||
let providerLogFolderSize = try await store.vpnConfigurationManager.getLogFolderSize()
|
||||
let providerLogFolderSize = try await store.ipcClient().getLogFolderSize()
|
||||
let totalSize = logFolderSize + providerLogFolderSize
|
||||
#else
|
||||
let totalSize = logFolderSize
|
||||
@@ -627,8 +627,8 @@ public struct SettingsView: View {
|
||||
return byteCountFormatter.string(fromByteCount: Int64(totalSize))
|
||||
|
||||
} catch {
|
||||
if let error = error as? VPNConfigurationManagerError,
|
||||
case VPNConfigurationManagerError.noIPCData = error {
|
||||
if let error = error as? IPCClient.Error,
|
||||
case IPCClient.Error.noIPCData = error {
|
||||
// Will happen if the extension is not enabled
|
||||
Log.warning("\(#function): Unable to count logs: \(error). Is the XPC service running?")
|
||||
} else {
|
||||
@@ -648,7 +648,7 @@ public struct SettingsView: View {
|
||||
try Log.clear(in: SharedAccess.logFolderURL)
|
||||
|
||||
#if os(macOS)
|
||||
try await store.vpnConfigurationManager.clearLogs()
|
||||
try await store.clearLogs()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,12 @@ struct macOSAlert { // swiftlint:disable:this type_name
|
||||
// Code 12
|
||||
case .requestSuperseded:
|
||||
// This will happen if the user repeatedly clicks "Enable ..."
|
||||
return nil
|
||||
return """
|
||||
You must enable the FirezoneNetworkExtension System Extension in System Settings to continue. Until you do,
|
||||
all functionality will be disabled.
|
||||
|
||||
For more information and troubleshooting, please contact your administrator.
|
||||
"""
|
||||
|
||||
// Code 13
|
||||
case .authorizationRequired:
|
||||
|
||||
@@ -67,14 +67,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
guard
|
||||
let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)?
|
||||
.providerConfiguration as? [String: String],
|
||||
let logFilter = providerConfiguration[VPNConfigurationManagerKeys.logFilter]
|
||||
let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter]
|
||||
else {
|
||||
throw PacketTunnelProviderError
|
||||
.savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter")
|
||||
}
|
||||
|
||||
// Hydrate telemetry account slug
|
||||
guard let accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug]
|
||||
guard let accountSlug = providerConfiguration[VPNConfigurationManager.Keys.accountSlug]
|
||||
else {
|
||||
// This can happen if the user deletes the VPN configuration while it's
|
||||
// connected. The system will try to restart us with a fresh config
|
||||
@@ -88,7 +88,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
let internetResourceEnabled: Bool =
|
||||
if let internetResourceEnabledJSON = providerConfiguration[
|
||||
VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) {
|
||||
VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) {
|
||||
(try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false
|
||||
} else {
|
||||
false
|
||||
@@ -165,11 +165,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// we need to update ProviderMessage 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 }
|
||||
guard let providerMessage = try? PropertyListDecoder().decode(ProviderMessage.self, from: message) else { return }
|
||||
|
||||
switch tunnelMessage {
|
||||
switch providerMessage {
|
||||
case .internetResourceEnabled(let value):
|
||||
adapter?.setInternetResourceEnabled(value)
|
||||
case .signOut:
|
||||
|
||||
Reference in New Issue
Block a user