feat(apple): permit resources to be disabled (#6215)

Work for #6074 equivalent to #6166 for MacOS

MacOs view:

<img width="547" alt="image"
src="https://github.com/user-attachments/assets/f465183e-247b-49b5-a916-3ecc5f0a02f4">


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.
This commit is contained in:
Gabi
2024-08-10 01:20:14 -03:00
committed by GitHub
parent a52f459da6
commit db655dd171
9 changed files with 246 additions and 47 deletions

View File

@@ -60,6 +60,9 @@ mod ffi {
// <https://github.com/firezone/firezone/issues/4350>
#[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()
}

View File

@@ -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<String>)
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<String>.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<String> = []
// 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<String>.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)

View File

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

View File

@@ -10,6 +10,7 @@ struct Settings: Equatable {
var authBaseURL: String
var apiURL: String
var logFilter: String
var disabledResources: Set<String>
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<String> {
guard let disabledResourcesJSON = disabledResources, let disabledResourcesData = disabledResourcesJSON.data(using: .utf8) else{
return Set()
}
return (try? JSONDecoder().decode(Set<String>.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
}()

View File

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

View File

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

View File

@@ -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<AnyCancellable> = []
@@ -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<Bool>(
get: { model.isResourceEnabled(resource.id) },
set: { newValue in
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
}
)).labelsHidden()
}
}
}
.navigationTitle("All Resources")
}
}
.listStyle(GroupedListStyle())
}
} else {

View File

@@ -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<String> = []
/// Cache of resources that can be disabled
private var canBeDisabled: Set<String> = []
/// Adapter state.
private var state: AdapterState {
didSet {
@@ -79,6 +85,7 @@ class Adapter {
apiURL: String,
token: String,
logFilter: String,
disabledResources: Set<String>,
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<String>) {
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()
}
}

View File

@@ -78,10 +78,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
let disabledResources: Set<String> = if let disabledResourcesJSON = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) {
(try? JSONDecoder().decode(Set<String>.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)")
}
}
}