From db655dd171e4f1377da95e0cc2bf36a6b62eea16 Mon Sep 17 00:00:00 2001 From: Gabi Date: Sat, 10 Aug 2024 01:20:14 -0300 Subject: [PATCH] feat(apple): permit resources to be disabled (#6215) Work for #6074 equivalent to #6166 for MacOS MacOs view: image iOS(ipad) view: ![image](https://github.com/user-attachments/assets/e64da75a-c69f-4e6a-aeeb-739958c3b046) Other than implementing the resource disabling, this PR also refactor the IPC between the network extension and the app so that it's some form of structured IPC instead of relying on it being deserializable to string to match the message. One big difference with Android is that we don't introduce the concept of a `ResourceView` for swift, the main reason for this is that on iOS the resources are bound to the view instead of just being a parameter for creating the view. So if we modify the `disabled` property it'd update the UI unnecessarily, also it'd update the `Store` value for the resource and then we need to copy that over again to the view. Making it easier to go out of sync. --- rust/connlib/clients/apple/src/lib.rs | 8 ++ .../FirezoneKit/Managers/TunnelManager.swift | 89 +++++++++++++++++-- .../Sources/FirezoneKit/Models/Resource.swift | 4 +- .../Sources/FirezoneKit/Models/Settings.swift | 26 ++++-- .../Sources/FirezoneKit/Stores/Store.swift | 15 +++- .../Sources/FirezoneKit/Views/MenuBar.swift | 30 +++++-- .../FirezoneKit/Views/SessionView.swift | 32 +++++-- .../FirezoneNetworkExtension/Adapter.swift | 39 ++++++++ .../PacketTunnelProvider.swift | 50 ++++++----- 9 files changed, 246 insertions(+), 47 deletions(-) diff --git a/rust/connlib/clients/apple/src/lib.rs b/rust/connlib/clients/apple/src/lib.rs index 60d7324e3..528dea26b 100644 --- a/rust/connlib/clients/apple/src/lib.rs +++ b/rust/connlib/clients/apple/src/lib.rs @@ -60,6 +60,9 @@ mod ffi { // #[swift_bridge(swift_name = "setDns")] fn set_dns(&mut self, dns_servers: String); + + #[swift_bridge(swift_name = "setDisabledResources")] + fn set_disabled_resources(&mut self, disabled_resources: String); fn disconnect(self); } @@ -234,6 +237,11 @@ impl WrappedSession { .set_dns(serde_json::from_str(&dns_servers).unwrap()) } + fn set_disabled_resources(&mut self, disabled_resources: String) { + self.inner + .set_disabled_resources(serde_json::from_str(&disabled_resources).unwrap()) + } + fn disconnect(self) { self.inner.disconnect() } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index 3d0b3d882..83200460b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -20,9 +20,55 @@ public enum TunnelManagerKeys { static let authBaseURL = "authBaseURL" static let apiURL = "apiURL" public static let logFilter = "logFilter" + public static let disabledResources = "disabledResources" } -class TunnelManager { +public enum TunnelMessage: Codable { + case getResourceList(Data) + case signOut + case setDisabledResources(Set) + + enum CodingKeys: String, CodingKey { + case type + case value + } + + enum MessageType: String, Codable { + case getResourceList + case signOut + case setDisabledResources + } + + 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 .setDisabledResources: + let value = try container.decode(Set.self, forKey: .value) + self = .setDisabledResources(value) + case .getResourceList: + let value = try container.decode(Data.self, forKey: .value) + self = .getResourceList(value) + case .signOut: + self = .signOut + } + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .setDisabledResources(let value): + try container.encode(MessageType.setDisabledResources, 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) + } + } +} + +public class TunnelManager { // Expose closures that someone else can use to respond to events // for this manager. var statusChangeHandler: ((NEVPNStatus) async -> Void)? @@ -36,11 +82,17 @@ class TunnelManager { // Cache resources on this side of the IPC barrier so we can // return them to callers when they haven't changed. - private var resourcesListCache = Data() + private var resourcesListCache: [Resource] = [] // Persists our tunnel settings private var manager: NETunnelProviderManager? + // Resources that are currently disabled and will not be used + public var disabledResources: Set = [] + + // Encoder used to send messages to the tunnel + private let encoder = PropertyListEncoder() + // Use separate bundle IDs for release and debug. // Helps with testing releases and dev builds on the same Mac. #if DEBUG @@ -69,6 +121,7 @@ class TunnelManager { protocolConfiguration.serverAddress = settings.apiURL manager.localizedDescription = bundleDescription manager.protocolConfiguration = protocolConfiguration + encoder.outputFormat = .binary // Save the new VPN profile to System Preferences and reload it, // which should update our status from invalid -> disconnected. @@ -101,6 +154,10 @@ class TunnelManager { // Found it let settings = Settings.fromProviderConfiguration(providerConfiguration) let actorName = providerConfiguration[TunnelManagerKeys.actorName] + if let disabledResourcesData = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) { + self.disabledResources = (try? JSONDecoder().decode(Set.self, from: disabledResourcesData)) ?? Set() + + } let status = manager.connection.status // Share what we found with our caller @@ -188,7 +245,7 @@ class TunnelManager { func stop(clearToken: Bool = false) { if clearToken { do { - try session().sendProviderMessage("signOut".data(using: .utf8)!) { _ in + try session().sendProviderMessage(encoder.encode(TunnelMessage.signOut)) { _ in self.session().stopTunnel() } } catch { @@ -199,14 +256,32 @@ class TunnelManager { } } - func fetchResources(callback: @escaping (Data) -> Void) { + func updateDisabledResources() { + guard session().status == .connected else { return } + + try? session().sendProviderMessage(encoder.encode(TunnelMessage.setDisabledResources(disabledResources))) { _ in } + } + + func toggleResourceDisabled(resource: String, enabled: Bool) { + if enabled { + disabledResources.remove(resource) + } else { + disabledResources.insert(resource) + } + + updateDisabledResources() + } + + func fetchResources(callback: @escaping ([Resource]) -> Void) { guard session().status == .connected else { return } do { - try session().sendProviderMessage(resourceListHash) { data in + try session().sendProviderMessage(encoder.encode(TunnelMessage.getResourceList(resourceListHash))) { data in if let data = data { self.resourceListHash = Data(SHA256.hash(data: data)) - self.resourcesListCache = data + var decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + self.resourcesListCache = (try? decoder.decode([Resource].self, from: data)) ?? [] } callback(self.resourcesListCache) @@ -248,7 +323,7 @@ class TunnelManager { if session.status == .disconnected { // Reset resource list on disconnect resourceListHash = Data() - resourcesListCache = Data() + resourcesListCache = [] } await statusChangeHandler?(session.status) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift index 682c82c89..9321a1070 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift @@ -16,8 +16,9 @@ public struct Resource: Decodable, Identifiable, Equatable { public var status: ResourceStatus public var sites: [Site] public var type: ResourceType + public var canToggle: Bool - public init(id: String, name: String, address: String, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType) { + public init(id: String, name: String, address: String, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType, canToggle: Bool) { self.id = id self.name = name self.address = address @@ -25,6 +26,7 @@ public struct Resource: Decodable, Identifiable, Equatable { self.status = status self.sites = sites self.type = type + self.canToggle = canToggle } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index 1faed470d..58951a1fb 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -10,6 +10,7 @@ struct Settings: Equatable { var authBaseURL: String var apiURL: String var logFilter: String + var disabledResources: Set var isValid: Bool { let authBaseURL = URL(string: authBaseURL) @@ -26,6 +27,7 @@ struct Settings: Equatable { && !logFilter.isEmpty } + // Convert provider configuration (which may have empty fields if it was tampered with) to Settings static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { if let providerConfiguration = providerConfiguration as? [String: String] { @@ -35,19 +37,29 @@ struct Settings: Equatable { apiURL: providerConfiguration[TunnelManagerKeys.apiURL] ?? Settings.defaultValue.apiURL, logFilter: providerConfiguration[TunnelManagerKeys.logFilter] - ?? Settings.defaultValue.logFilter + ?? Settings.defaultValue.logFilter, + disabledResources: getDisabledResources(disabledResources: providerConfiguration[TunnelManagerKeys.disabledResources]) ) } else { return Settings.defaultValue } } + static private func getDisabledResources(disabledResources: String?) -> Set { + guard let disabledResourcesJSON = disabledResources, let disabledResourcesData = disabledResourcesJSON.data(using: .utf8) else{ + return Set() + } + return (try? JSONDecoder().decode(Set.self, from: disabledResourcesData)) + ?? Settings.defaultValue.disabledResources + } + // Used for initializing a new providerConfiguration from Settings func toProviderConfiguration() -> [String: String] { return [ - "authBaseURL": authBaseURL, - "apiURL": apiURL, - "logFilter": logFilter, + TunnelManagerKeys.authBaseURL: authBaseURL, + TunnelManagerKeys.apiURL: apiURL, + TunnelManagerKeys.logFilter: logFilter, + TunnelManagerKeys.disabledResources: String(data: try! JSONEncoder().encode(disabledResources), encoding: .utf8) ?? "", ] } @@ -59,13 +71,15 @@ struct Settings: Equatable { authBaseURL: "https://app.firez.one", apiURL: "wss://api.firez.one", logFilter: - "firezone_tunnel=debug,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn" + "firezone_tunnel=debug,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn", + disabledResources: Set() ) #else Settings( authBaseURL: "https://app.firezone.dev", apiURL: "wss://api.firezone.dev", - logFilter: "str0m=warn,info" + logFilter: "str0m=warn,info", + disabledResources: Set() ) #endif }() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 2a750936a..0d6e36d95 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -45,6 +45,10 @@ public final class Store: ObservableObject { initTunnelManager() } + public func isResourceEnabled(_ id: String) -> Bool { + !tunnelManager.disabledResources.contains(id) + } + private func initNotifications() { // Finish initializing notification binding sessionNotification.signInHandler = { @@ -138,7 +142,7 @@ public final class Store: ObservableObject { // Network Extensions don't have a 2-way binding up to the GUI process, // so we need to periodically ask the tunnel process for them. - func beginUpdatingResources(callback: @escaping (Data) -> Void) { + func beginUpdatingResources(callback: @escaping ([Resource]) -> Void) { Log.app.log("\(#function)") tunnelManager.fetchResources(callback: callback) @@ -167,6 +171,15 @@ public final class Store: ObservableObject { } } + func toggleResourceDisabled(resource: String, enabled: Bool) { + tunnelManager.toggleResourceDisabled(resource: resource, enabled: enabled) + var newSettings = settings + newSettings.disabledResources = tunnelManager.disabledResources + Task { + try await save(newSettings) + } + } + // Handles the frequent VPN state changes during sign in, sign out, etc. private func handleVPNStatusChange(status: NEVPNStatus) async { self.status = status diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 347fac208..03877c786 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -59,15 +59,11 @@ public final class MenuBar: NSObject, ObservableObject { guard let self = self else { return } if status == .connected { - model.store.beginUpdatingResources { data in - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - if let newResources = try? decoder.decode([Resource].self, from: data) { + model.store.beginUpdatingResources { newResources in // Handle resource changes self.populateResourceMenu(newResources) self.handleTunnelStatusOrResourcesChanged(status: status, resources: newResources) self.resources = newResources - } } } else { model.store.endUpdatingResources() @@ -424,6 +420,10 @@ public final class MenuBar: NSObject, ObservableObject { return item } + private func resourceTitle(_ id: String) -> String { + model.isResourceEnabled(id) ? "Disable this resource" : "Enable this resource" + } + private func createSubMenu(resource: Resource) -> NSMenu { let subMenu = NSMenu() let resourceAddressDescriptionItem = NSMenuItem() @@ -433,6 +433,7 @@ public final class MenuBar: NSObject, ObservableObject { let siteSectionItem = NSMenuItem() let siteNameItem = NSMenuItem() let siteStatusItem = NSMenuItem() + let enableToggle = NSMenuItem() // AddressDescription first -- will be most common action @@ -484,6 +485,18 @@ public final class MenuBar: NSObject, ObservableObject { resourceAddressItem.target = self subMenu.addItem(resourceAddressItem) + // Resource toggle + if resource.canToggle { + subMenu.addItem(NSMenuItem.separator()) + enableToggle.action = #selector(resourceToggle(_:)) + enableToggle.title = resourceTitle(resource.id) + enableToggle.toolTip = "Enable or disable resource" + enableToggle.isEnabled = true + enableToggle.target = self + enableToggle.representedObject = resource.id + subMenu.addItem(enableToggle) + } + // Site details if let site = resource.sites.first { subMenu.addItem(NSMenuItem.separator()) @@ -526,6 +539,13 @@ public final class MenuBar: NSObject, ObservableObject { } } + @objc private func resourceToggle(_ sender: NSMenuItem) { + let id = sender.representedObject as! String + + self.model.store.toggleResourceDisabled(resource: id, enabled: !model.isResourceEnabled(id)) + sender.title = resourceTitle(id) + } + @objc private func resourceURLTapped(_ sender: AnyObject?) { if let value = (sender as? NSMenuItem)?.title { // URL has already been validated diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index f40601baa..bb76f20f9 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -15,6 +15,7 @@ public final class SessionViewModel: ObservableObject { @Published private(set) var resources: [Resource]? = nil @Published private(set) var status: NEVPNStatus? = nil + let store: Store private var cancellables: Set = [] @@ -40,10 +41,8 @@ public final class SessionViewModel: ObservableObject { self.status = status if status == .connected { - store.beginUpdatingResources() { data in - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - self.resources = try? decoder.decode([Resource].self, from: data) + store.beginUpdatingResources() { resources in + self.resources = resources } } else { store.endUpdatingResources() @@ -54,6 +53,10 @@ public final class SessionViewModel: ObservableObject { #endif } + public func isResourceEnabled(_ resource: String) -> Bool { + store.isResourceEnabled(resource) + } + } #if os(iOS) @@ -69,9 +72,26 @@ struct SessionView: View { Text("No Resources. Contact your admin to be granted access.") } else { List(resources) { resource in - NavigationLink(resource.name, destination: ResourceView(resource: resource)) - .navigationTitle("All Resources") + HStack { + NavigationLink { ResourceView(resource: resource) } + label: { + HStack { + Text(resource.name) + if resource.canToggle { + Spacer() + Toggle("Enabled", isOn: Binding( + get: { model.isResourceEnabled(resource.id) }, + set: { newValue in + model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue) + } + )).labelsHidden() + } + } + } + .navigationTitle("All Resources") + } } + .listStyle(GroupedListStyle()) } } else { diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index e71892855..ad676d6c8 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -59,6 +59,12 @@ class Adapter { /// This is the primary async primitive used in this class. private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue") + /// Currently disabled resources + private var disabledResources: Set = [] + + /// Cache of resources that can be disabled + private var canBeDisabled: Set = [] + /// Adapter state. private var state: AdapterState { didSet { @@ -79,6 +85,7 @@ class Adapter { apiURL: String, token: String, logFilter: String, + disabledResources: Set, packetTunnelProvider: PacketTunnelProvider ) { self.apiURL = apiURL @@ -89,6 +96,7 @@ class Adapter { self.logFilter = logFilter self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" self.networkSettings = nil + self.disabledResources = disabledResources } // Could happen abruptly if the process is killed. @@ -187,6 +195,35 @@ class Adapter { } } } + + func resources() -> [Resource] { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + guard let resourceList = resourceListJSON else { return [] } + return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? [] + } + + public func setDisabledResources(newDisabledResources: Set) { + workQueue.async { [weak self] in + guard let self = self else { return } + self.disabledResources = newDisabledResources + self.resourcesUpdated() + } + } + + public func resourcesUpdated() { + guard case .tunnelStarted(let session) = self.state else { return } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + canBeDisabled = Set(resources().filter({ $0.canToggle }).map({ $0.id })) + + let disablingResources = disabledResources.filter({ canBeDisabled.contains($0) }) + + let currentlyDisabled = try! JSONEncoder().encode(disablingResources) + session.setDisabledResources(String(data: currentlyDisabled, encoding: .utf8)!) + } } // MARK: Responding to path updates @@ -370,6 +407,8 @@ extension Adapter: CallbackHandlerDelegate { // Update resource List. We don't care what's inside. resourceListJSON = resourceList + + self.resourcesUpdated() } } diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index ffc0f8c04..07279ddbb 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -78,10 +78,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + let disabledResources: Set = if let disabledResourcesJSON = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) { + (try? JSONDecoder().decode(Set.self, from: disabledResourcesJSON )) ?? Set() + } else { + Set() + } + let adapter = Adapter( - apiURL: apiURL, token: token, logFilter: logFilter, packetTunnelProvider: self) + apiURL: apiURL, token: token, logFilter: logFilter, disabledResources: disabledResources, packetTunnelProvider: self) self.adapter = adapter + try adapter.start() // Tell the system the tunnel is up, moving the tunnelManager status to @@ -128,37 +135,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider { cancelTunnelWithError(nil) } - // TODO: Use a message format to allow requesting different types of data. - // This currently assumes we're requesting resources. override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { - let string = String(data: message, encoding: .utf8) + guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return } - switch string { - case "signOut": + switch tunnelMessage { + case .setDisabledResources(let value): + adapter?.setDisabledResources(newDisabledResources: value) + case .signOut: Task { - do { - try await clearToken() - } catch { - Log.tunnel.error("\(#function): Error: \(error)") - } + await clearToken() } - default: - adapter?.getResourcesIfVersionDifferentFrom(hash: message) { + case .getResourceList(let value): + adapter?.getResourcesIfVersionDifferentFrom(hash: value) { resourceListJSON in completionHandler?(resourceListJSON?.data(using: .utf8)) } } } - private func clearToken() async throws { - let keychain = Keychain() - guard let ref = await keychain.search() - else { - Log.tunnel.error("\(#function): Error: token not found!") - return - } + enum TokenError: Error { + case TokenNotFound + } - try await keychain.delete(persistentRef: ref) + private func clearToken() async { + do { + let keychain = Keychain() + guard let ref = await keychain.search() else { + throw TokenError.TokenNotFound + } + try await keychain.delete(persistentRef: ref) + } catch { + Log.tunnel.error("\(#function): Error: \(error)") + } } }