mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +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) {
|
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
|
||||||
|
|||||||
@@ -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.
|
// 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user