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:
Jamil
2025-02-28 04:35:26 +00:00
committed by GitHub
parent 1bd8051aae
commit c9f085c102
16 changed files with 655 additions and 569 deletions

View File

@@ -76,6 +76,7 @@ struct FirezoneApp: App {
func applicationDidFinishLaunching(_: Notification) { func applicationDidFinishLaunching(_: Notification) {
if let store { if let store {
menuBar = MenuBar(store: store) menuBar = MenuBar(store: store)
AppView.subscribeToGlobalEvents(store: store)
} }
// SwiftUI will show the first window group, so close it on launch // SwiftUI will show the first window group, so close it on launch

View File

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

View File

@@ -105,8 +105,8 @@ public final class Log {
// because these happen often due to code signing requirements. // because these happen often due to code signing requirements.
private static func shouldCaptureError(_ err: Error) -> Bool { private static func shouldCaptureError(_ err: Error) -> Bool {
#if DEBUG #if DEBUG
if let err = err as? VPNConfigurationManagerError, if let err = err as? IPCClient.Error,
case VPNConfigurationManagerError.noIPCData = err { case IPCClient.Error.noIPCData = err {
return false return false
} }
#endif #endif

View File

@@ -28,7 +28,7 @@ enum LogExporter {
static func export( static func export(
to archiveURL: URL, to archiveURL: URL,
with vpnConfigurationManager: VPNConfigurationManager with ipcClient: IPCClient
) async throws { ) async throws {
guard let logFolderURL = SharedAccess.logFolderURL guard let logFolderURL = SharedAccess.logFolderURL
else { else {
@@ -53,7 +53,7 @@ enum LogExporter {
// 3. Await tunnel log export from tunnel process // 3. Await tunnel log export from tunnel process
try await withCheckedThrowingContinuation { continuation in try await withCheckedThrowingContinuation { continuation in
vpnConfigurationManager.exportLogs( ipcClient.exportLogs(
appender: { chunk in appender: { chunk in
do { do {
// Append each chunk to the archive // Append each chunk to the archive

View File

@@ -7,151 +7,41 @@
// Abstracts the nitty gritty of loading and saving to our // Abstracts the nitty gritty of loading and saving to our
// VPN configuration in system preferences. // VPN configuration in system preferences.
// TODO: Refactor to fix file length
// swiftlint:disable file_length
import CryptoKit
import Foundation import Foundation
import NetworkExtension import NetworkExtension
enum VPNConfigurationManagerError: Error { enum VPNConfigurationManagerError: Error {
case managerNotInitialized case managerNotInitialized
case cannotLoad case savedProtocolConfigurationIsInvalid
case decodeIPCDataFailed
case invalidNotification
case noIPCData
case invalidStatus(NEVPNStatus)
var localizedDescription: String { var localizedDescription: String {
switch self { switch self {
case .managerNotInitialized: case .managerNotInitialized:
return "Manager doesn't seem initialized." return "NETunnelProviderManager is not yet initialized. Race condition?"
case .decodeIPCDataFailed: case .savedProtocolConfigurationIsInvalid:
return "Decoding IPC data failed." return "Saved protocol configuration is invalid. Check types?"
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)."
} }
} }
} }
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 { public class VPNConfigurationManager {
public enum Keys {
// Connect status updates with our listeners static let actorName = "actorName"
private var tunnelObservingTasks: [Task<Void, Never>] = [] static let authBaseURL = "authBaseURL"
static let apiURL = "apiURL"
// Track the "version" of the resource list so we can more efficiently public static let accountSlug = "accountSlug"
// retrieve it from the Provider public static let logFilter = "logFilter"
private var resourceListHash = Data() public static let internetResourceEnabled = "internetResourceEnabled"
}
// 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
// Persists our tunnel settings // Persists our tunnel settings
private var manager: NETunnelProviderManager? let 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
}()
public static let bundleIdentifier: String = "\(Bundle.main.bundleIdentifier!).network-extension" 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 // Initialize and save a new VPN configuration in system Preferences
func create() async throws { init() async throws {
let protocolConfiguration = NETunnelProviderProtocol() let protocolConfiguration = NETunnelProviderProtocol()
let manager = NETunnelProviderManager() let manager = NETunnelProviderManager()
let settings = Settings.defaultValue let settings = Settings.defaultValue
@@ -159,119 +49,100 @@ public class VPNConfigurationManager {
protocolConfiguration.providerConfiguration = settings.toProviderConfiguration() protocolConfiguration.providerConfiguration = settings.toProviderConfiguration()
protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier
protocolConfiguration.serverAddress = settings.apiURL protocolConfiguration.serverAddress = settings.apiURL
manager.localizedDescription = bundleDescription manager.localizedDescription = VPNConfigurationManager.bundleDescription
manager.protocolConfiguration = protocolConfiguration manager.protocolConfiguration = protocolConfiguration
// Save the new VPN configuration to System Preferences and reload it, try await manager.saveToPreferences()
// which should update our status from nil -> disconnected. try await manager.loadFromPreferences()
// 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")
return self.manager = manager
}
throw error
}
} }
func loadFromPreferences( init(from manager: NETunnelProviderManager) {
vpnStateUpdateHandler: @escaping @MainActor (NEVPNStatus, Settings?, String?, NEProviderStopReason?) async -> Void self.manager = manager
) async throws { }
static func load() async throws -> VPNConfigurationManager? {
// loadAllFromPreferences() returns list of VPN configurations created by our main app's bundle ID. // 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. // Since our bundle ID can change (by us), find the one that's current and ignore the others.
let managers = try await NETunnelProviderManager.loadAllFromPreferences() let managers = try await NETunnelProviderManager.loadAllFromPreferences()
Log.log("\(#function): \(managers.count) tunnel managers found") Log.log("\(#function): \(managers.count) tunnel managers found")
for manager in managers where manager.localizedDescription == bundleDescription { for manager in managers where manager.localizedDescription == bundleDescription {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, return VPNConfigurationManager(from: manager)
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
} }
// If no tunnel configuration was found, update state to return nil
// prompt user to create one.
if manager == nil {
await vpnStateUpdateHandler(.invalid, nil, nil, nil)
}
// Hook up status updates
subscribeToVPNStatusUpdates(handler: vpnStateUpdateHandler)
} }
func saveAuthResponse(_ authResponse: AuthResponse) async throws { func actorName() throws -> String? {
guard let manager = manager, guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
var providerConfiguration = protocolConfiguration.providerConfiguration
else { else {
throw VPNConfigurationManagerError.managerNotInitialized throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
} }
providerConfiguration[VPNConfigurationManagerKeys.actorName] = authResponse.actorName return providerConfiguration[Keys.actorName]
providerConfiguration[VPNConfigurationManagerKeys.accountSlug] = authResponse.accountSlug }
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 protocolConfiguration.providerConfiguration = providerConfiguration
manager.protocolConfiguration = protocolConfiguration manager.protocolConfiguration = protocolConfiguration
// We always set this to true when starting the tunnel in case our tunnel // Always set this to true when starting the tunnel in case our tunnel was disabled by the system.
// was disabled by the system for some reason.
manager.isEnabled = true manager.isEnabled = true
try await manager.saveToPreferences() try await manager.saveToPreferences()
try await manager.loadFromPreferences() try await manager.loadFromPreferences()
} }
func saveSettings(_ settings: Settings) async throws { func save(settings: Settings) async throws {
guard let manager = manager, guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else { else {
throw VPNConfigurationManagerError.managerNotInitialized throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
} }
var newProviderConfiguration = settings.toProviderConfiguration() var newProviderConfiguration = settings.toProviderConfiguration()
// Don't clobber existing actorName // Don't clobber existing actorName
newProviderConfiguration[VPNConfigurationManagerKeys.actorName] = newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName]
providerConfiguration[VPNConfigurationManagerKeys.actorName]
protocolConfiguration.providerConfiguration = newProviderConfiguration protocolConfiguration.providerConfiguration = newProviderConfiguration
protocolConfiguration.serverAddress = settings.apiURL protocolConfiguration.serverAddress = settings.apiURL
manager.protocolConfiguration = protocolConfiguration 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 manager.isEnabled = true
try await manager.saveToPreferences() try await manager.saveToPreferences()
@@ -281,236 +152,17 @@ public class VPNConfigurationManager {
Telemetry.setEnvironmentOrClose(settings.apiURL) Telemetry.setEnvironmentOrClose(settings.apiURL)
} }
func start(token: String? = nil) throws { func settings() throws -> Settings {
var options: [String: NSObject] = [:] guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
// 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
else { else {
throw VPNConfigurationManagerError.managerNotInitialized throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
} }
if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) { return Settings.fromProviderConfiguration(providerConfiguration)
return session
}
throw VPNConfigurationManagerError.invalidStatus(session.status)
} }
// Subscribe to system notifications about our VPN status changing func session() -> NETunnelProviderSession? {
// and let our handler know about them. return manager.connection as? NETunnelProviderSession
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)
}
}
)
} }
} }

View File

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

View File

@@ -31,14 +31,14 @@ struct Settings: Equatable {
static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings {
if let providerConfiguration = providerConfiguration as? [String: String] { if let providerConfiguration = providerConfiguration as? [String: String] {
return Settings( return Settings(
authBaseURL: providerConfiguration[VPNConfigurationManagerKeys.authBaseURL] authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL]
?? Settings.defaultValue.authBaseURL, ?? Settings.defaultValue.authBaseURL,
apiURL: providerConfiguration[VPNConfigurationManagerKeys.apiURL] apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL]
?? Settings.defaultValue.apiURL, ?? Settings.defaultValue.apiURL,
logFilter: providerConfiguration[VPNConfigurationManagerKeys.logFilter] logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter]
?? Settings.defaultValue.logFilter, ?? Settings.defaultValue.logFilter,
internetResourceEnabled: getInternetResourceEnabled( internetResourceEnabled: getInternetResourceEnabled(
internetResourceEnabled: providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled]) internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled])
) )
} else { } else {
return Settings.defaultValue return Settings.defaultValue
@@ -62,10 +62,10 @@ struct Settings: Equatable {
} }
return [ return [
VPNConfigurationManagerKeys.authBaseURL: authBaseURL, VPNConfigurationManager.Keys.authBaseURL: authBaseURL,
VPNConfigurationManagerKeys.apiURL: apiURL, VPNConfigurationManager.Keys.apiURL: apiURL,
VPNConfigurationManagerKeys.logFilter: logFilter, VPNConfigurationManager.Keys.logFilter: logFilter,
VPNConfigurationManagerKeys.internetResourceEnabled: string VPNConfigurationManager.Keys.internetResourceEnabled: string
] ]
} }

View File

@@ -20,7 +20,7 @@ public final class Store: ObservableObject {
@Published private(set) var actorName: String? @Published private(set) var actorName: String?
// Make our tunnel configuration convenient for SettingsView to consume // 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 // Enacapsulate Tunnel status here to make it easier for other components
// to observe // to observe
@@ -28,54 +28,61 @@ public final class Store: ObservableObject {
@Published private(set) var decision: UNAuthorizationStatus? @Published private(set) var decision: UNAuthorizationStatus?
@Published private(set) var internetResourceEnabled: Bool?
#if os(macOS) #if os(macOS)
// Track whether our system extension has been installed (macOS) // Track whether our system extension has been installed (macOS)
@Published private(set) var systemExtensionStatus: SystemExtensionStatus? @Published private(set) var systemExtensionStatus: SystemExtensionStatus?
#endif #endif
let vpnConfigurationManager: VPNConfigurationManager let sessionNotification = SessionNotification()
private var sessionNotification: SessionNotification
private var cancellables: Set<AnyCancellable> = []
private var resourcesTimer: Timer? private var resourcesTimer: Timer?
private var resourceUpdateTask: Task<Void, Never>?
private var vpnConfigurationManager: VPNConfigurationManager?
public init() { public init() {
// Initialize all stored properties
self.settings = Settings.defaultValue
self.sessionNotification = SessionNotification()
self.vpnConfigurationManager = VPNConfigurationManager()
self.sessionNotification.signInHandler = { self.sessionNotification.signInHandler = {
Task { Task {
do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) } 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 { Task {
// Load user's decision whether to allow / disallow notifications
self.decision = await self.sessionNotification.loadAuthorizationStatus() self.decision = await self.sessionNotification.loadAuthorizationStatus()
}
}
// Load VPN configuration and system extension status func initSystemExtension() {
do {
try await self.bindToVPNConfigurationUpdates()
let vpnConfigurationStatus = self.status
#if os(macOS) #if os(macOS)
let systemExtensionStatus = try await self.checkedSystemExtensionStatus() Task {
do {
if systemExtensionStatus != .installed self.systemExtensionStatus = try await self.checkSystemExtensionStatus()
|| vpnConfigurationStatus == .invalid { } catch {
Log.error(error)
// Show the main Window if VPN permission needs to be granted }
AppView.WindowDefinition.main.openWindow() }
} else {
AppView.WindowDefinition.main.window()?.close()
}
#endif #endif
}
if vpnConfigurationStatus == .disconnected { func initVPNConfiguration() {
Task {
// Try to connect on start do {
try self.vpnConfigurationManager.start() // 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 { } catch {
Log.error(error) Log.error(error)
@@ -83,56 +90,65 @@ public final class Store: ObservableObject {
} }
} }
public func internetResourceEnabled() -> Bool { func setupTunnelObservers(autoStart: Bool = false) async throws {
self.vpnConfigurationManager.internetResourceEnabled 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 { func handleStatusChange(newStatus: NEVPNStatus) async throws {
// Load our existing VPN configuration and set an update handler status = newStatus
try await self.vpnConfigurationManager.loadFromPreferences(
vpnStateUpdateHandler: { @MainActor [weak self] status, settings, actorName, stopReason in
guard let self else { return }
self.status = status if status == .connected {
// Load saved actorName
actorName = try? manager().actorName()
if let settings { // Load saved internet resource status
self.settings = settings internetResourceEnabled = try? manager().internetResourceEnabled()
}
if let actorName { // Load Resources
self.actorName = actorName beginUpdatingResources()
} } else {
endUpdatingResources()
if status == .connected { }
self.beginUpdatingResources { resourceList in
self.resourceList = resourceList
}
}
if status == .disconnected {
self.endUpdatingResources()
self.resourceList = ResourceList.loading
}
#if os(macOS) #if os(macOS)
// On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification // 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. // from the tunnel process, because the UI process is not guaranteed to be alive.
if status == .disconnected, if status == .disconnected {
stopReason == .authenticationCanceled { do {
let reason = try await ipcClient().consumeStopReason()
if reason == .authenticationCanceled {
await self.sessionNotification.showSignedOutAlertmacOS() 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) #if os(macOS)
func checkedSystemExtensionStatus() async throws -> SystemExtensionStatus { func checkSystemExtensionStatus() async throws -> SystemExtensionStatus {
let checker = SystemExtensionManager() let checker = SystemExtensionManager()
let status = let status =
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
checker.checkStatus( checker.checkStatus(
identifier: VPNConfigurationManager.bundleIdentifier, identifier: VPNConfigurationManager.bundleIdentifier,
continuation: continuation continuation: continuation
@@ -145,8 +161,6 @@ public final class Store: ObservableObject {
try await installSystemExtension() try await installSystemExtension()
} }
self.systemExtensionStatus = status
return status return status
} }
@@ -157,7 +171,6 @@ public final class Store: ObservableObject {
// See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers // See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers
self.systemExtensionStatus = self.systemExtensionStatus =
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
installer.installSystemExtension( installer.installSystemExtension(
identifier: VPNConfigurationManager.bundleIdentifier, identifier: VPNConfigurationManager.bundleIdentifier,
continuation: continuation continuation: continuation
@@ -166,12 +179,29 @@ public final class Store: ObservableObject {
} }
#endif #endif
func grantVPNPermission() async throws { func installVPNConfiguration() async throws {
// Create a new VPN configuration in system settings. // Create a new VPN configuration in system settings.
try await self.vpnConfigurationManager.create() self.vpnConfigurationManager = try await VPNConfigurationManager()
// Reload our state try await setupTunnelObservers()
try await bindToVPNConfigurationUpdates() }
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 { func grantNotifications() async throws {
@@ -182,34 +212,48 @@ public final class Store: ObservableObject {
return URL(string: settings.authBaseURL) return URL(string: settings.authBaseURL)
} }
private func start(token: String? = nil) throws {
try self.vpnConfigurationManager.start(token: token)
}
func stop() throws { func stop() throws {
try self.vpnConfigurationManager.stop() try ipcClient().stop()
} }
func signIn(authResponse: AuthResponse) async throws { func signIn(authResponse: AuthResponse) async throws {
// Save actorName // Save actorName
self.actorName = authResponse.actorName self.actorName = authResponse.actorName
try await self.vpnConfigurationManager.saveSettings(settings) try await manager().save(authResponse: authResponse)
try await self.vpnConfigurationManager.saveAuthResponse(authResponse)
// Bring the tunnel up and send it a token to start // 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 { 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, // 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. // so we need to periodically ask the tunnel process for them.
func beginUpdatingResources(callback: @escaping @MainActor (ResourceList) -> Void) { private func beginUpdatingResources() {
Log.log("\(#function)")
if self.resourcesTimer != nil { if self.resourcesTimer != nil {
// Prevent duplicate timer scheduling. This will happen if the system sends us two .connected status updates // Prevent duplicate timer scheduling. This will happen if the system sends us two .connected status updates
// in a row, which can happen occasionally. // in a row, which can happen occasionally.
@@ -219,11 +263,17 @@ public final class Store: ObservableObject {
// Define the Timer's closure // Define the Timer's closure
let updateResources: @Sendable (Timer) -> Void = { _ in let updateResources: @Sendable (Timer) -> Void = { _ in
Task { Task {
do { await MainActor.run {
let resources = try await self.vpnConfigurationManager.fetchResources() self.resourceUpdateTask?.cancel()
await callback(resources) self.resourceUpdateTask = Task {
} catch { if !Task.isCancelled {
Log.error(error) do {
self.resourceList = try await self.ipcClient().fetchResources()
} catch {
Log.error(error)
}
}
}
} }
} }
} }
@@ -240,20 +290,10 @@ public final class Store: ObservableObject {
updateResources(timer) updateResources(timer)
} }
func endUpdatingResources() { private func endUpdatingResources() {
resourceUpdateTask?.cancel()
resourcesTimer?.invalidate() resourcesTimer?.invalidate()
resourcesTimer = nil resourcesTimer = nil
} resourceList = ResourceList.loading
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)
} }
} }

View File

@@ -22,6 +22,29 @@ public struct AppView: View {
@EnvironmentObject var store: Store @EnvironmentObject var store: Store
#if os(macOS) #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 { public enum WindowDefinition: String, CaseIterable {
case main case main
case settings case settings

View File

@@ -8,6 +8,10 @@
import SwiftUI import SwiftUI
import Combine import Combine
#if os(macOS)
import SystemExtensions
#endif
struct GrantVPNView: View { struct GrantVPNView: View {
@EnvironmentObject var store: Store @EnvironmentObject var store: Store
@EnvironmentObject var errorHandler: GlobalErrorHandler @EnvironmentObject var errorHandler: GlobalErrorHandler
@@ -36,7 +40,7 @@ struct GrantVPNView: View {
.imageScale(.large) .imageScale(.large)
Spacer() Spacer()
Button("Grant VPN Permission") { Button("Grant VPN Permission") {
grantVPNPermission() installVPNConfiguration()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
@@ -108,7 +112,7 @@ struct GrantVPNView: View {
Spacer() Spacer()
Button( Button(
action: { action: {
grantVPNPermission() installVPNConfiguration()
}, },
label: { label: {
Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled") Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled")
@@ -144,10 +148,21 @@ struct GrantVPNView: View {
} }
} }
func grantVPNPermission() { func installVPNConfiguration() {
Task { Task {
do { 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 { } catch {
Log.error(error) Log.error(error)
await macOSAlert.show(for: error) await macOSAlert.show(for: error)
@@ -161,10 +176,10 @@ struct GrantVPNView: View {
#endif #endif
#if os(iOS) #if os(iOS)
func grantVPNPermission() { func installVPNConfiguration() {
Task { Task {
do { do {
try await store.grantVPNPermission() try await store.installVPNConfiguration()
} catch { } catch {
Log.error(error) Log.error(error)

View File

@@ -23,7 +23,7 @@ public final class MenuBar: NSObject, ObservableObject {
var statusItem: NSStatusItem var statusItem: NSStatusItem
var lastShownFavorites: [Resource] = [] var lastShownFavorites: [Resource] = []
var lastShownOthers: [Resource] = [] var lastShownOthers: [Resource] = []
var wasInternetResourceEnabled: Bool = false var wasInternetResourceEnabled: Bool?
var cancellables: Set<AnyCancellable> = [] var cancellables: Set<AnyCancellable> = []
var updateChecker: UpdateChecker = UpdateChecker() var updateChecker: UpdateChecker = UpdateChecker()
var updateMenuDisplayed: Bool = false var updateMenuDisplayed: Bool = false
@@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject {
} }
} }
lastShownFavorites = newFavorites lastShownFavorites = newFavorites
wasInternetResourceEnabled = store.internetResourceEnabled() wasInternetResourceEnabled = store.internetResourceEnabled
} }
func populateOtherResourcesMenu(_ newOthers: [Resource]) { func populateOtherResourcesMenu(_ newOthers: [Resource]) {
@@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject {
} }
} }
lastShownOthers = newOthers lastShownOthers = newOthers
wasInternetResourceEnabled = store.internetResourceEnabled() wasInternetResourceEnabled = store.internetResourceEnabled
} }
func updateStatusItemIcon() { func updateStatusItemIcon() {
@@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject {
return false return false
} }
return wasInternetResourceEnabled != store.internetResourceEnabled() return wasInternetResourceEnabled != store.internetResourceEnabled
} }
func refreshUpdateItem() { func refreshUpdateItem() {
@@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject {
} }
func internetResourceTitle(resource: Resource) -> String { 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 return status + " " + resource.name
} }
@@ -526,7 +526,7 @@ public final class MenuBar: NSObject, ObservableObject {
} }
func internetResourceToggleTitle() -> String { 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 // 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 // the system extension here too just in case. It's a no-op if already
// installed. // installed.
try await store.installSystemExtension() 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 { } catch {
Log.error(error) Log.error(error)
await macOSAlert.show(for: error) await macOSAlert.show(for: error)
@@ -756,7 +766,7 @@ public final class MenuBar: NSObject, ObservableObject {
@objc func internetResourceToggle(_ sender: NSMenuItem) { @objc func internetResourceToggle(_ sender: NSMenuItem) {
Task { Task {
do { do {
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) try await store.toggleInternetResource()
} catch { } catch {
Log.error(error) Log.error(error)
} }

View File

@@ -235,7 +235,7 @@ struct ToggleInternetResourceButton: View {
@EnvironmentObject var store: Store @EnvironmentObject var store: Store
private func toggleResourceEnabledText() -> String { private func toggleResourceEnabledText() -> String {
if store.internetResourceEnabled() { if store.internetResourceEnabled == true {
"Disable this resource" "Disable this resource"
} else { } else {
"Enable this resource" "Enable this resource"
@@ -247,7 +247,7 @@ struct ToggleInternetResourceButton: View {
action: { action: {
Task { Task {
do { do {
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) try await store.toggleInternetResource()
} catch { } catch {
Log.error(error) Log.error(error)
} }

View File

@@ -81,7 +81,7 @@ struct ResourceSection: View {
@EnvironmentObject var store: Store @EnvironmentObject var store: Store
private func internetResourceTitle(resource: Resource) -> String { 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 return status + " " + resource.name
} }

View File

@@ -522,13 +522,13 @@ public struct SettingsView: View {
do { do {
try await LogExporter.export( try await LogExporter.export(
to: destinationURL, to: destinationURL,
with: store.vpnConfigurationManager with: store.ipcClient()
) )
window.contentViewController?.presentingViewController?.dismiss(self) window.contentViewController?.presentingViewController?.dismiss(self)
} catch { } catch {
if let error = error as? VPNConfigurationManagerError, if let error = error as? IPCClient.Error,
case VPNConfigurationManagerError.noIPCData = error { case IPCClient.Error.noIPCData = error {
Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?") Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?")
} else { } else {
Log.error(error) Log.error(error)
@@ -584,7 +584,7 @@ public struct SettingsView: View {
try self.store.signOut() try self.store.signOut()
} }
try await store.save(settings) try await store.saveSettings(settings)
} catch { } catch {
Log.error(error) Log.error(error)
} }
@@ -613,7 +613,7 @@ public struct SettingsView: View {
do { do {
#if os(macOS) #if os(macOS)
let providerLogFolderSize = try await store.vpnConfigurationManager.getLogFolderSize() let providerLogFolderSize = try await store.ipcClient().getLogFolderSize()
let totalSize = logFolderSize + providerLogFolderSize let totalSize = logFolderSize + providerLogFolderSize
#else #else
let totalSize = logFolderSize let totalSize = logFolderSize
@@ -627,8 +627,8 @@ public struct SettingsView: View {
return byteCountFormatter.string(fromByteCount: Int64(totalSize)) return byteCountFormatter.string(fromByteCount: Int64(totalSize))
} catch { } catch {
if let error = error as? VPNConfigurationManagerError, if let error = error as? IPCClient.Error,
case VPNConfigurationManagerError.noIPCData = error { case IPCClient.Error.noIPCData = error {
// Will happen if the extension is not enabled // Will happen if the extension is not enabled
Log.warning("\(#function): Unable to count logs: \(error). Is the XPC service running?") Log.warning("\(#function): Unable to count logs: \(error). Is the XPC service running?")
} else { } else {
@@ -648,7 +648,7 @@ public struct SettingsView: View {
try Log.clear(in: SharedAccess.logFolderURL) try Log.clear(in: SharedAccess.logFolderURL)
#if os(macOS) #if os(macOS)
try await store.vpnConfigurationManager.clearLogs() try await store.clearLogs()
#endif #endif
} }
} }

View File

@@ -168,7 +168,12 @@ struct macOSAlert { // swiftlint:disable:this type_name
// Code 12 // Code 12
case .requestSuperseded: case .requestSuperseded:
// This will happen if the user repeatedly clicks "Enable ..." // 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 // Code 13
case .authorizationRequired: case .authorizationRequired:

View File

@@ -67,14 +67,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
guard guard
let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)?
.providerConfiguration as? [String: String], .providerConfiguration as? [String: String],
let logFilter = providerConfiguration[VPNConfigurationManagerKeys.logFilter] let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter]
else { else {
throw PacketTunnelProviderError throw PacketTunnelProviderError
.savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter") .savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter")
} }
// Hydrate telemetry account slug // Hydrate telemetry account slug
guard let accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug] guard let accountSlug = providerConfiguration[VPNConfigurationManager.Keys.accountSlug]
else { else {
// This can happen if the user deletes the VPN configuration while it's // 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 // connected. The system will try to restart us with a fresh config
@@ -88,7 +88,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let internetResourceEnabled: Bool = let internetResourceEnabled: Bool =
if let internetResourceEnabledJSON = providerConfiguration[ if let internetResourceEnabledJSON = providerConfiguration[
VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) { VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) {
(try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false
} else { } else {
false false
@@ -165,11 +165,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
} }
// It would be helpful to be able to encapsulate Errors here. To do that // 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) { 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): case .internetResourceEnabled(let value):
adapter?.setInternetResourceEnabled(value) adapter?.setInternetResourceEnabled(value)
case .signOut: case .signOut: