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)") + } } }