feat(apple): Handle network changes reliably on macOS and iOS (#4133)

Tried to organize this PR into commits so that it's a bit easier to
review.

1. Involves simplifying the logic in Adapter.swift so that us mortals
can maintain it confidently:
- The `.stoppingTunnel`, `.stoppedTunnelTemporarily`, and
`.stoppingTunnelTemporarily` states have been removed.
- I also removed the `self.` prefix from local vars when it's not
necessary to use it, to be more consistent.
- `onTunnelReady` and `getSystemDefaultResolvers` has been removed, and
`onUpdateRoutes` wired up, along with cleanup necessary to support that.
2. Involves adding the `reconnect` and `set_dns` stubs in the FFI and
fixing the log filter so that we can log them (see #4182 )
3. Involves getting the path update handler working well on macOS using
`SystemConfiguration` to read DNS servers.
4. Involves getting the path update handler working well on iOS by
employing careful trickery to prevent path update cycles by detecting if
`path.gateways` has changed, and avoid setting new DNS if it hasn't.

Refs #4028 
Fixes #4297
Fixes #3565 
Fixes #3429 
Fixes #4175 
Fixes #4176 
Fixes #4309

---------

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Co-authored-by: Reactor Scram <ReactorScram@users.noreply.github.com>
This commit is contained in:
Jamil
2024-03-26 20:00:22 -07:00
committed by GitHub
parent 24e0641871
commit ab598eff91
33 changed files with 1381 additions and 2446 deletions

17
rust/Cargo.lock generated
View File

@@ -3401,12 +3401,9 @@ dependencies = [
[[package]]
name = "line-wrap"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
dependencies = [
"safemem",
]
checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e"
[[package]]
name = "linked-hash-map"
@@ -4510,9 +4507,9 @@ checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c"
[[package]]
name = "plist"
version = "1.6.0"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef"
checksum = "d9d34169e64b3c7a80c8621a48adaf44e0cf62c78a9b25dd9dd35f1881a17cf9"
dependencies = [
"base64 0.21.7",
"indexmap 2.2.6",
@@ -5241,12 +5238,6 @@ dependencies = [
"libc",
]
[[package]]
name = "safemem"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
[[package]]
name = "same-file"
version = "1.0.6"

View File

@@ -44,6 +44,9 @@ mod ffi {
callback_handler: CallbackHandler,
) -> Result<WrappedSession, String>;
fn reconnect(&mut self);
#[swift_bridge(swift_name = "setDns")]
fn set_dns(&mut self, dns_servers: String);
fn disconnect(self);
}
@@ -58,9 +61,6 @@ mod ffi {
dnsAddresses: String,
);
#[swift_bridge(swift_name = "onTunnelReady")]
fn on_tunnel_ready(&self);
#[swift_bridge(swift_name = "onUpdateRoutes")]
fn on_update_routes(&self, routeList4: String, routeList6: String);
@@ -69,10 +69,6 @@ mod ffi {
#[swift_bridge(swift_name = "onDisconnect")]
fn on_disconnect(&self, error: String);
// TODO: remove in favor of set_dns
#[swift_bridge(swift_name = "getSystemDefaultResolvers")]
fn get_system_default_resolvers(&self) -> String;
}
}
@@ -115,10 +111,6 @@ impl Callbacks for CallbackHandler {
None
}
fn on_tunnel_ready(&self) {
self.inner.on_tunnel_ready();
}
fn on_update_routes(
&self,
route_list_4: Vec<Cidrv4>,
@@ -182,10 +174,6 @@ impl WrappedSession {
let handle = init_logging(log_dir.into(), log_filter).map_err(|e| e.to_string())?;
let secret = SecretString::from(token);
let resolvers_json = callback_handler.get_system_default_resolvers();
let resolvers: Vec<IpAddr> = serde_json::from_str(&resolvers_json)
.expect("developer error: failed to deserialize resolvers");
let (private_key, public_key) = keypair();
let login = LoginUrl::client(
api_url.as_str(),
@@ -217,14 +205,21 @@ impl WrappedSession {
)
.map_err(|err| err.to_string())?;
session.set_dns(resolvers);
Ok(Self {
inner: session,
runtime,
})
}
fn reconnect(&mut self) {
self.inner.reconnect()
}
fn set_dns(&mut self, dns_servers: String) {
self.inner
.set_dns(serde_json::from_str(&dns_servers).unwrap())
}
fn disconnect(self) {
self.inner.disconnect()
}

View File

@@ -14,8 +14,6 @@
05CF1D16290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; };
05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; };
05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; };
6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA39A032A6A7248000F0157 /* NetworkResource.swift */; };
6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA39A032A6A7248000F0157 /* NetworkResource.swift */; };
6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; };
6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; };
6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; };
@@ -30,8 +28,10 @@
794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; };
794C38172970A26A0029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38162970A26A0029F38F /* FirezoneKit */; };
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; };
8D28EB992B35FBD70083621C /* Resolv.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D28EB982B35FBD70083621C /* Resolv.swift */; };
8D28EB9A2B35FBD70083621C /* Resolv.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D28EB982B35FBD70083621C /* Resolv.swift */; };
8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; };
8D69392F2BA2502000AF4396 /* DeviceMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */; };
8D6939302BA2502000AF4396 /* DeviceMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */; };
8D6939322BA2521A00AF4396 /* SystemConfigurationResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */; };
8DC08BCB2B296C4500675F46 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BCA2B296C4500675F46 /* libresolv.9.tbd */; };
8DC08BCD2B296C5900675F46 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */; };
8DC08BD22B297B7B00675F46 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DC08BD12B297B7B00675F46 /* libresolv.tbd */; };
@@ -108,7 +108,6 @@
05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FirezoneNetworkExtension_iOS.entitlements; sourceTree = "<group>"; };
05CF1D03290B1DCD00CF4755 /* FirezoneNetworkExtensionmacOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirezoneNetworkExtensionmacOS.appex; sourceTree = BUILT_PRODUCTS_DIR; };
05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
6FA39A032A6A7248000F0157 /* NetworkResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResource.swift; sourceTree = "<group>"; };
6FE454EA2A5BFABA006549B1 /* Adapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = "<group>"; };
6FE455082A5D110D006549B1 /* CallbackHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallbackHandler.swift; sourceTree = "<group>"; };
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = "<group>"; };
@@ -116,7 +115,9 @@
6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = "<group>"; };
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = "<group>"; };
6FFECD5B2AD6998400E00273 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
8D28EB982B35FBD70083621C /* Resolv.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resolv.swift; sourceTree = "<group>"; };
8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = "<group>"; };
8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMetadata.swift; sourceTree = "<group>"; };
8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = "<group>"; };
8DC08BCA2B296C4500675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.2.sdk/usr/lib/libresolv.9.tbd; sourceTree = DEVELOPER_DIR; };
8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; };
8DC08BD12B297B7B00675F46 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
@@ -177,17 +178,18 @@
05833DF928F73B070008FAB0 /* FirezoneNetworkExtension */ = {
isa = PBXGroup;
children = (
8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */,
8D69392E2BA2502000AF4396 /* DeviceMetadata.swift */,
8D69392B2BA24FE600AF4396 /* BindResolvers.swift */,
05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension_iOS.entitlements */,
05CF1CDE290B1A9000CF4755 /* FirezoneNetworkExtension_macOS.entitlements */,
05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */,
6FE454EA2A5BFABA006549B1 /* Adapter.swift */,
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */,
6FA39A032A6A7248000F0157 /* NetworkResource.swift */,
6FE455082A5D110D006549B1 /* CallbackHandler.swift */,
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */,
6FE4550E2A5D112C006549B1 /* connlib-client-apple.swift */,
6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */,
8D28EB982B35FBD70083621C /* Resolv.swift */,
);
path = FirezoneNetworkExtension;
sourceTree = "<group>";
@@ -498,12 +500,12 @@
files = (
8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */,
6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */,
8D28EB992B35FBD70083621C /* Resolv.swift in Sources */,
6FE4550F2A5D112C006549B1 /* connlib-client-apple.swift in Sources */,
05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */,
6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */,
6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */,
6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */,
8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */,
8D69392F2BA2502000AF4396 /* DeviceMetadata.swift in Sources */,
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -514,13 +516,13 @@
files = (
8DC08BD62B297DA400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */,
6FE4550A2A5D110D006549B1 /* CallbackHandler.swift in Sources */,
8D28EB9A2B35FBD70083621C /* Resolv.swift in Sources */,
6FE455102A5D112C006549B1 /* connlib-client-apple.swift in Sources */,
05CF1D16290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */,
8D6939302BA2502000AF4396 /* DeviceMetadata.swift in Sources */,
6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */,
6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */,
6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */,
6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */,
8D6939322BA2521A00AF4396 /* SystemConfigurationResolvers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -29,7 +29,8 @@ struct FirezoneApp: App {
StateObject(
wrappedValue: AskPermissionViewModel(
tunnelStore: appStore.tunnelStore,
sessionNotificationHelper: SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore)
sessionNotificationHelper: SessionNotificationHelper(
logger: appStore.logger, tunnelStore: appStore.tunnelStore)
)
)
appDelegate.appStore = appStore
@@ -47,7 +48,7 @@ struct FirezoneApp: App {
}
#else
WindowGroup(
"Firezone (VPN Permission)",
"Welcome to Firezone",
id: AppStore.WindowDefinition.askPermission.identifier
) {
AskPermissionView(model: askPermissionViewModel)
@@ -74,23 +75,16 @@ struct FirezoneApp: App {
private var isAppLaunched = false
private var menuBar: MenuBar?
public var appStore: AppStore? {
didSet {
if self.isAppLaunched {
// This is not expected to happen because appStore
// should be set before the app finishes launching.
// This code is only a contingency.
if let appStore = self.appStore {
self.menuBar = MenuBar(appStore: appStore)
}
}
}
}
public var appStore: AppStore?
func applicationDidFinishLaunching(_: Notification) {
self.isAppLaunched = true
if let appStore = self.appStore {
self.menuBar = MenuBar(appStore: appStore)
self.menuBar = MenuBar(
tunnelStore: appStore.tunnelStore,
settingsViewModel: appStore.settingsViewModel,
logger: appStore.logger
)
}
// SwiftUI will show the first window group, so close it on launch
@@ -98,7 +92,7 @@ struct FirezoneApp: App {
}
func applicationWillTerminate(_: Notification) {
self.appStore?.authStore.cancelSignIn()
self.appStore?.tunnelStore.cancelSignIn()
}
}
#endif

View File

@@ -33,14 +33,13 @@ public final class AskPermissionViewModel: ObservableObject {
self.tunnelStore = tunnelStore
self.sessionNotificationHelper = sessionNotificationHelper
tunnelStore.$tunnelAuthStatus
.filter { $0.isInitialized }
.sink { [weak self] tunnelAuthStatus in
tunnelStore.$status
.sink { [weak self] status in
guard let self = self else { return }
Task {
await MainActor.run {
if case .noTunnelFound = tunnelAuthStatus {
if case .invalid = status {
self.needsTunnelPermission = true
} else {
self.needsTunnelPermission = false
@@ -66,19 +65,12 @@ public final class AskPermissionViewModel: ObservableObject {
}
}
.store(in: &cancellables)
}
func grantPermissionButtonTapped() {
Task {
do {
try await self.tunnelStore.createTunnel()
} catch {
#if os(macOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront()
}
#endif
try await self.tunnelStore.createManager()
}
}
}
@@ -90,7 +82,7 @@ public final class AskPermissionViewModel: ObservableObject {
#endif
#if os(macOS)
func closeAskPermissionWindow() {
public func closeAskPermissionWindow() {
AppStore.WindowDefinition.askPermission.window()?.close()
}
#endif
@@ -111,11 +103,10 @@ public struct AskPermissionView: View {
Image("LogoText")
.resizable()
.scaledToFit()
.frame(maxWidth: 600)
.frame(maxWidth: 320)
.padding(.horizontal, 10)
Spacer()
if $model.needsTunnelPermission.wrappedValue {
#if os(macOS)
Text(
"Firezone requires your permission to create VPN tunnels.\nUntil it has that permission, all functionality will be disabled."
@@ -180,7 +171,6 @@ public struct AskPermissionView: View {
.multilineTextAlignment(.center)
#endif
} else {
#if os(macOS)
Text(
"You can sign in to Firezone by clicking on the Firezone icon in the macOS menu bar.\nYou may now close this window."

View File

@@ -13,19 +13,19 @@ import XCTestDynamicOverlay
@MainActor
final class AuthViewModel: ObservableObject {
let authStore: AuthStore
let tunnelStore: TunnelStore
var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined")
private var cancellables = Set<AnyCancellable>()
init(authStore: AuthStore) {
self.authStore = authStore
init(tunnelStore: TunnelStore) {
self.tunnelStore = tunnelStore
}
func signInButtonTapped() async {
do {
try await authStore.signIn()
try await tunnelStore.signIn()
} catch {
dump(error)
}

View File

@@ -15,70 +15,47 @@ import SwiftUI
final class MainViewModel: ObservableObject {
private let logger: AppLogger
private var cancellables: Set<AnyCancellable> = []
let tunnelStore: TunnelStore
let appStore: AppStore
@Dependency(\.mainQueue) private var mainQueue
@Published var loginStatus: AuthStore.LoginStatus = .uninitialized
@Published var tunnelStatus: NEVPNStatus = .invalid
@Published var orderedResources: [DisplayableResources.Resource] = []
@Published private(set) var resources: [Resource]?
init(appStore: AppStore) {
self.appStore = appStore
self.logger = appStore.logger
init(tunnelStore: TunnelStore, logger: AppLogger) {
self.tunnelStore = tunnelStore
self.logger = logger
setupObservers()
}
private func setupObservers() {
appStore.authStore.$loginStatus
.receive(on: mainQueue)
.sink { [weak self] loginStatus in
self?.loginStatus = loginStatus
}
.store(in: &cancellables)
appStore.tunnelStore.$status
tunnelStore.$status
.receive(on: mainQueue)
.sink { [weak self] status in
self?.tunnelStatus = status
guard let self = self else { return }
if status == .connected {
self?.appStore.tunnelStore.beginUpdatingResources()
self.tunnelStore.beginUpdatingResources()
} else {
self?.appStore.tunnelStore.endUpdatingResources()
self.tunnelStore.endUpdatingResources()
}
}
.store(in: &cancellables)
appStore.tunnelStore.$resources
tunnelStore.$resourceListJSON
.receive(on: mainQueue)
.sink { [weak self] resources in
guard let self = self else { return }
self.orderedResources = resources.orderedResources.map {
DisplayableResources.Resource(name: $0.name, location: $0.location)
}
.sink { [weak self] json in
guard let self = self,
let json = json,
let data = json.data(using: .utf8)
else { return }
resources = try? JSONDecoder().decode([Resource].self, from: data)
}
.store(in: &cancellables)
}
func signOutButtonTapped() {
Task {
await appStore.authStore.signOut()
}
}
func startTunnel() async {
if case .signedIn = self.loginStatus {
appStore.authStore.startTunnel()
}
}
func stopTunnel() {
Task {
do {
try await appStore.tunnelStore.stop()
} catch {
logger.error("\(#function): Error stopping tunnel: \(error)")
}
try await tunnelStore.signOut()
}
}
}
@@ -90,56 +67,53 @@ import SwiftUI
List {
Section(header: Text("Authentication")) {
Group {
switch self.model.loginStatus {
case .signedIn(let actorName):
if self.model.tunnelStatus == .connected {
HStack {
Text(actorName.isEmpty ? "Signed in" : "Signed in as")
Spacer()
Text(actorName)
.foregroundColor(.secondary)
}
HStack {
Spacer()
Button("Sign Out") {
self.model.signOutButtonTapped()
}
Spacer()
}
} else {
Text(self.model.tunnelStatus.description)
if case .connected = model.tunnelStore.status {
let actorName = model.tunnelStore.actorName() ?? ""
HStack {
Text(actorName.isEmpty ? "Signed in" : "Signed in as")
Spacer()
Text(actorName).foregroundColor(.secondary)
}
case .signedOut:
Text("Signed Out")
case .uninitialized:
Text("Initializing…")
case .needsTunnelCreationPermission:
Text("Requires VPN permission")
HStack {
Spacer()
Button("Sign Out") {
model.signOutButtonTapped()
}
Spacer()
}
} else {
Text(model.tunnelStore.status.description)
}
}
}
if case .signedIn = self.model.loginStatus, self.model.tunnelStatus == .connected {
if case .connected = model.tunnelStore.status {
Section(header: Text("Resources")) {
if self.model.orderedResources.isEmpty {
Text("No resources")
} else {
ForEach(self.model.orderedResources) { resource in
Menu(content: {
Button {
self.copyResourceTapped(resource)
} label: {
Label("Copy Address", systemImage: "doc.on.doc")
}
}, label : {
HStack {
Text(resource.name)
.foregroundColor(.primary)
Spacer()
Text(resource.location)
.foregroundColor(.secondary)
}
})
if let resources = model.resources {
if resources.isEmpty {
Text("No Resources")
} else {
ForEach(resources) { resource in
Menu(
content: {
Button {
copyResourceTapped(resource)
} label: {
Label("Copy Address", systemImage: "doc.on.doc")
}
},
label: {
HStack {
Text(resource.name)
.foregroundColor(.primary)
Spacer()
Text(resource.address)
.foregroundColor(.secondary)
}
})
}
}
} else {
Text("Loading Resources...")
}
}
}
@@ -148,9 +122,9 @@ import SwiftUI
.navigationTitle("Firezone")
}
private func copyResourceTapped(_ resource: DisplayableResources.Resource) {
private func copyResourceTapped(_ resource: Resource) {
let pasteboard = UIPasteboard.general
pasteboard.string = resource.location
pasteboard.string = resource.address
}
}
#endif

View File

@@ -16,63 +16,44 @@ enum SettingsViewError: Error {
}
public final class SettingsViewModel: ObservableObject {
let authStore: AuthStore
let tunnelStore: TunnelStore
var tunnelAuthStatus: TunnelAuthStatus {
authStore.tunnelStore.tunnelAuthStatus
}
@Published var advancedSettings: AdvancedSettings
@Published var settings: Settings
let logger: AppLogger
public var onSettingsSaved: () -> Void = unimplemented()
private var cancellables = Set<AnyCancellable>()
public init(authStore: AuthStore, logger: AppLogger) {
self.authStore = authStore
public init(tunnelStore: TunnelStore, logger: AppLogger) {
self.tunnelStore = tunnelStore
self.logger = logger
advancedSettings = AdvancedSettings.defaultValue
settings = Settings.defaultValue
loadSettings()
}
func loadSettings() {
// Load settings from saved VPN Profile
Task {
authStore.tunnelStore.$tunnelAuthStatus
.first { $0.isInitialized }
tunnelStore.$settings
.receive(on: RunLoop.main)
.sink { [weak self] tunnelAuthStatus in
.sink { [weak self] settings in
guard let self = self else { return }
self.advancedSettings =
authStore.tunnelStore.advancedSettings() ?? AdvancedSettings.defaultValue
self.settings = settings
}
.store(in: &cancellables)
}
}
func saveAdvancedSettings() {
let isChanged = (authStore.tunnelStore.advancedSettings() != advancedSettings)
guard isChanged else {
advancedSettings.isSavedToDisk = true
return
}
func saveSettings() {
Task {
if case .signedIn = self.tunnelAuthStatus {
await authStore.signOut()
}
let authBaseURLString = advancedSettings.authBaseURLString
guard URL(string: authBaseURLString) != nil else {
logger.error(
"Not saving advanced settings because authBaseURL '\(authBaseURLString)' is invalid"
)
return
if [.connected, .connecting, .reasserting].contains(tunnelStore.status) {
_ = try await tunnelStore.signOut()
}
do {
try await authStore.tunnelStore.saveAdvancedSettings(advancedSettings)
try await tunnelStore.save(settings)
} catch {
logger.error("Error saving advanced settings to tunnel store: \(error)")
}
await MainActor.run {
advancedSettings.isSavedToDisk = true
logger.error("Error saving settings to tunnel store: \(error)")
}
}
}
@@ -80,7 +61,6 @@ public final class SettingsViewModel: ObservableObject {
func calculateLogDirSize(logger: AppLogger) -> String? {
logger.log("\(#function)")
let startTime = DispatchTime.now()
guard let logFilesFolderURL = SharedAccess.logFolderURL else {
logger.error("\(#function): Log folder is unavailable")
return nil
@@ -107,10 +87,6 @@ public final class SettingsViewModel: ObservableObject {
return nil
}
let elapsedTime =
(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000
logger.log("\(#function): Finished calculating (\(totalSize) bytes) in \(elapsedTime) ms")
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.countStyle = .file
byteCountFormatter.allowsNonnumericFormatting = false
@@ -121,7 +97,6 @@ public final class SettingsViewModel: ObservableObject {
func clearAllLogs(logger: AppLogger) throws {
logger.log("\(#function)")
let startTime = DispatchTime.now()
guard let logFilesFolderURL = SharedAccess.logFolderURL else {
logger.error("\(#function): Log folder is unavailable")
return
@@ -146,10 +121,6 @@ public final class SettingsViewModel: ObservableObject {
}
}
let elapsedTime =
(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000
logger.log("\(#function): Finished removing log files in \(elapsedTime) ms")
if unremovedFilesCount > 0 {
logger.log("\(#function): Unable to remove \(unremovedFilesCount) files")
}
@@ -196,15 +167,15 @@ public struct SettingsView: View {
enum ConfirmationAlertContinueAction: Int {
case none
case saveAdvancedSettings
case saveSettings
case saveAllSettingsAndDismiss
func performAction(on view: SettingsView) {
switch self {
case .none:
break
case .saveAdvancedSettings:
view.saveAdvancedSettings()
case .saveSettings:
view.saveSettings()
case .saveAllSettingsAndDismiss:
view.saveAllSettingsAndDismiss()
}
@@ -235,7 +206,7 @@ public struct SettingsView: View {
static let forAdvanced = try! AttributedString(
markdown: """
**WARNING:** These settings are intended for internal debug purposes **only**. \
Changing these is not supported and will disrupt access to your Firezone resources.
Changing these will disrupt access to your Firezone resources.
""")
}
@@ -253,7 +224,7 @@ public struct SettingsView: View {
Image(systemName: "slider.horizontal.3")
Text("Advanced")
}
.badge(model.advancedSettings.isValid ? nil : "!")
.badge(model.settings.isValid ? nil : "!")
logsTab
.tabItem {
Image(systemName: "doc.text")
@@ -264,7 +235,7 @@ public struct SettingsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let action = ConfirmationAlertContinueAction.saveAllSettingsAndDismiss
if case .signedIn = model.tunnelAuthStatus {
if case .connected = model.tunnelStore.status {
self.confirmationAlertContinueAction = action
self.isShowingConfirmationAlert = true
} else {
@@ -272,7 +243,7 @@ public struct SettingsView: View {
}
}
.disabled(
(model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid)
(model.settings == model.tunnelStore.settings || !model.settings.isValid)
)
}
ToolbarItem(placement: .navigationBarLeading) {
@@ -347,8 +318,8 @@ public struct SettingsView: View {
TextField(
"Auth Base URL:",
text: Binding(
get: { model.advancedSettings.authBaseURLString },
set: { model.advancedSettings.authBaseURLString = $0 }
get: { model.settings.authBaseURL },
set: { model.settings.authBaseURL = $0 }
),
prompt: Text(PlaceholderText.authBaseURL)
)
@@ -356,8 +327,8 @@ public struct SettingsView: View {
TextField(
"API URL:",
text: Binding(
get: { model.advancedSettings.apiURLString },
set: { model.advancedSettings.apiURLString = $0 }
get: { model.settings.apiURL },
set: { model.settings.apiURL = $0 }
),
prompt: Text(PlaceholderText.apiURL)
)
@@ -365,8 +336,8 @@ public struct SettingsView: View {
TextField(
"Log Filter:",
text: Binding(
get: { model.advancedSettings.connlibLogFilterString },
set: { model.advancedSettings.connlibLogFilterString = $0 }
get: { model.settings.logFilter },
set: { model.settings.logFilter = $0 }
),
prompt: Text(PlaceholderText.logFilter)
)
@@ -378,8 +349,8 @@ public struct SettingsView: View {
Button(
"Apply",
action: {
let action = ConfirmationAlertContinueAction.saveAdvancedSettings
if case .signedIn = model.tunnelAuthStatus {
let action = ConfirmationAlertContinueAction.saveSettings
if [.connected, .connecting, .reasserting].contains(model.tunnelStore.status) {
self.confirmationAlertContinueAction = action
self.isShowingConfirmationAlert = true
} else {
@@ -387,15 +358,15 @@ public struct SettingsView: View {
}
}
)
.disabled(model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid)
.disabled(model.settings == model.tunnelStore.settings || !model.settings.isValid)
Button(
"Reset to Defaults",
action: {
self.restoreAdvancedSettingsToDefaults()
model.settings = Settings.defaultValue
}
)
.disabled(model.advancedSettings == AdvancedSettings.defaultValue)
.disabled(model.settings == Settings.defaultValue)
}
.padding(.top, 5)
}
@@ -416,8 +387,8 @@ public struct SettingsView: View {
TextField(
PlaceholderText.authBaseURL,
text: Binding(
get: { model.advancedSettings.authBaseURLString },
set: { model.advancedSettings.authBaseURLString = $0 }
get: { model.settings.authBaseURL },
set: { model.settings.authBaseURL = $0 }
)
)
.autocorrectionDisabled()
@@ -431,8 +402,8 @@ public struct SettingsView: View {
TextField(
PlaceholderText.apiURL,
text: Binding(
get: { model.advancedSettings.apiURLString },
set: { model.advancedSettings.apiURLString = $0 }
get: { model.settings.apiURL },
set: { model.settings.apiURL = $0 }
)
)
.autocorrectionDisabled()
@@ -446,8 +417,8 @@ public struct SettingsView: View {
TextField(
PlaceholderText.logFilter,
text: Binding(
get: { model.advancedSettings.connlibLogFilterString },
set: { model.advancedSettings.connlibLogFilterString = $0 }
get: { model.settings.logFilter },
set: { model.settings.logFilter = $0 }
)
)
.autocorrectionDisabled()
@@ -459,10 +430,10 @@ public struct SettingsView: View {
Button(
"Reset to Defaults",
action: {
self.restoreAdvancedSettingsToDefaults()
model.settings = Settings.defaultValue
}
)
.disabled(model.advancedSettings == AdvancedSettings.defaultValue)
.disabled(model.settings == Settings.defaultValue)
Spacer()
}
},
@@ -577,12 +548,12 @@ public struct SettingsView: View {
#endif
}
func saveAdvancedSettings() {
model.saveAdvancedSettings()
func saveSettings() {
model.saveSettings()
}
func saveAllSettingsAndDismiss() {
model.saveAdvancedSettings()
model.saveSettings()
dismiss()
}
@@ -591,14 +562,6 @@ public struct SettingsView: View {
dismiss()
}
func restoreAdvancedSettingsToDefaults() {
let defaultValue = AdvancedSettings.defaultValue
model.advancedSettings.authBaseURLString = defaultValue.authBaseURLString
model.advancedSettings.apiURLString = defaultValue.apiURLString
model.advancedSettings.connlibLogFilterString = defaultValue.connlibLogFilterString
model.saveAdvancedSettings()
}
#if os(macOS)
func exportLogsWithSavePanelOnMac() {
self.isExportingLogs = true

View File

@@ -49,7 +49,8 @@ import SwiftUINavigationCore
self.appStore = appStore
self.settingsViewModel = appStore.settingsViewModel
let sessionNotificationHelper = SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore)
let sessionNotificationHelper =
SessionNotificationHelper(logger: appStore.logger, tunnelStore: appStore.tunnelStore)
self.sessionNotificationHelper = sessionNotificationHelper
appStore.objectWillChange
@@ -58,28 +59,27 @@ import SwiftUINavigationCore
.store(in: &cancellables)
Publishers.CombineLatest(
appStore.authStore.$loginStatus,
appStore.tunnelStore.$status,
sessionNotificationHelper.$notificationDecision
)
.receive(on: mainQueue)
.sink(receiveValue: { [weak self] loginStatus, notificationDecision in
guard let self else {
return
}
switch (loginStatus, notificationDecision) {
case (.uninitialized, _), (_, .uninitialized):
self.state = .uninitialized
case (.needsTunnelCreationPermission, _), (_, .notDetermined):
.sink(receiveValue: { [weak self] status, notificationDecision in
guard let self = self else { return }
switch (status, notificationDecision) {
case (.invalid, _), (_, .notDetermined):
self.state = .needsPermission(
AskPermissionViewModel(
tunnelStore: self.appStore.tunnelStore,
sessionNotificationHelper: self.sessionNotificationHelper
sessionNotificationHelper: sessionNotificationHelper
)
)
case (.signedOut, .determined):
self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore))
case (.signedIn, .determined):
self.state = .authenticated(MainViewModel(appStore: self.appStore))
case (.disconnected, .determined):
self.state = .unauthenticated(AuthViewModel(tunnelStore: appStore.tunnelStore))
case (_, .determined):
self.state = .authenticated(MainViewModel(tunnelStore: appStore.tunnelStore, logger: appStore.logger))
case (_, .uninitialized):
self.state = .uninitialized
}
})
.store(in: &cancellables)

View File

@@ -9,446 +9,128 @@ import Foundation
import OSLog
public final class AppLogger {
public enum Process: String {
case app
case tunnel
public enum Category: String, Codable {
case app = "app"
case tunnel = "tunnel"
}
private let logger: Logger
private let folderURL: URL?
private let logWriter: LogWriter?
private let dateFormatter: DateFormatter
public init(process: Process, folderURL: URL?) {
let logger = Logger(subsystem: "dev.firezone.firezone", category: process.rawValue)
if folderURL == nil {
logger.log("AppLogger.init: folderURL is nil")
}
self.logger = logger
self.folderURL = folderURL
let target = LogWriter.Target(process: process)
self.logWriter = LogWriter(target: target, folderURL: folderURL, logger: self.logger)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS Z"
self.dateFormatter = dateFormatter
log("Starting logging")
let appVersionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "Unknown"
log("Firezone \(appVersionString) (\(process.rawValue) process)")
let osVersionString = ProcessInfo.processInfo.operatingSystemVersionString
#if os(iOS)
log("iOS \(osVersionString)")
#elseif os(macOS)
log("macOS \(osVersionString)")
#endif
public init(category: Category, folderURL: URL?) {
self.logger = Logger(subsystem: "dev.firezone.firezone", category: category.rawValue)
self.logWriter = LogWriter(category: category, folderURL: folderURL, logger: self.logger)
}
public func log(_ message: String) {
self.logger.log("\(message, privacy: .public)")
logWriter?.writeLogEntry(severity: .debug, message: message)
debug(message)
}
public func trace(_ message: String) {
logger.trace("\(message, privacy: .public)")
logWriter?.write(severity: .trace, message: message)
}
public func debug(_ message: String) {
self.logger.debug("\(message, privacy: .public)")
logWriter?.write(severity: .debug, message: message)
}
public func info(_ message: String) {
logger.info("\(message, privacy: .public)")
logWriter?.write(severity: .info, message: message)
}
public func warning(_ message: String) {
logger.warning("\(message, privacy: .public)")
logWriter?.write(severity: .warning, message: message)
}
public func error(_ message: String) {
self.logger.error("\(message, privacy: .public)")
logWriter?.writeLogEntry(severity: .error, message: message)
logWriter?.write(severity: .error, message: message)
}
}
private final class LogWriter {
struct DiskLog {
let logIndex: Int
let filePointer: UnsafeMutablePointer<FILE>
let fileSizeAtOpen: UInt64
}
class MemoryLog {
var data = Data()
func reset() {
self.data = Data()
}
}
enum Severity: String, Codable {
case trace = "TRACE"
case debug = "DEBUG"
case info = "INFO"
case warning = "WARN"
case error = "ERROR"
}
struct LogEntry: Codable {
let time: String
let target: Target
let category: AppLogger.Category
let severity: Severity
let message: String
}
private enum LogDestination {
// Normally, write log entries to disk
case disk(DiskLog?)
// While setting up the disk log, or when switching to
// a different log file, write to memory temporarily,
// so that it can be later written to disk
case memory(MemoryLog)
}
enum Target: String, Codable {
case appMacOS = "app_macos"
case appiOS = "app_ios"
case tunnelMacOS = "tunnel_macos"
case tunneliOS = "tunnel_ios"
init(process: AppLogger.Process) {
#if os(iOS)
switch process {
case .app: self = .appiOS
case .tunnel: self = .tunneliOS
}
#elseif os(macOS)
switch process {
case .app: self = .appMacOS
case .tunnel: self = .tunnelMacOS
}
#endif
}
}
private let target: Target
private let folderURL: URL
private let logger: Logger
// All log writes happen in the workQueue
private let workQueue: DispatchQueue
// Switching between log files happen in the diskLogSwitchingQueue
private let diskLogSwitchingQueue: DispatchQueue
private var logDestination: LogDestination
private var fileSizeAddendum: UInt64 = 0
private let category: AppLogger.Category
private let logger: Logger
private let logFileURL: URL
private let dateFormatter: ISO8601DateFormatter
private let jsonEncoder: JSONEncoder
private let newlineData: Data
private let logFileNameBase: String
private let currentIndexFileURL: URL
private static let logFileNameExtension = "log"
private static let currentIndexFileName = "firezone_log_current_index"
private static let maxLogFileSize = 1024 * 1024 * 1024 // 1 MB
private static let maxLogFilesCount = 5
init?(target: Target, folderURL: URL?, logger: Logger) {
guard let folderURL = folderURL else {
logger.error("LogWriter.init: folderURL is nil")
return nil
}
self.target = target
self.folderURL = folderURL
self.logger = logger
self.logFileNameBase = target.rawValue
self.currentIndexFileURL = self.folderURL.appendingPathComponent(Self.currentIndexFileName)
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
self.dateFormatter = dateFormatter
let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
self.jsonEncoder = jsonEncoder
self.newlineData = "\n".data(using: .utf8)!
workQueue = DispatchQueue(label: "LogWriter.workQueue", qos: .utility)
diskLogSwitchingQueue = DispatchQueue(
label: "LogWriter.diskLogSwitchingQueue", qos: .background)
self.logger.error("LogWriter.init: Temporarily using memory log")
logDestination = .memory(MemoryLog())
performInitialSetup()
}
private func performInitialSetup() {
diskLogSwitchingQueue.async {
let currentIndex: Int = {
let currentIndexFromFile: Int? = {
guard let currentIndexString = try? String(contentsOf: self.currentIndexFileURL) else {
return nil
}
return Int(currentIndexString)
}()
if let currentIndexFromFile = currentIndexFromFile {
self.logger.error(
"LogWriter.performInitialSetup: Current log index read from file: \(currentIndexFromFile, privacy: .public)"
)
return currentIndexFromFile
} else {
self.logger.error(
"LogWriter.performInitialSetup: Current log index could not be read from file. Assuming current log index as 0."
)
try? "0".write(to: self.currentIndexFileURL, atomically: true, encoding: .utf8)
return 0
}
}()
let diskLog = Self.openDiskLog(
folderURL: self.folderURL, logFileNameBase: self.logFileNameBase,
logIndex: currentIndex, shouldRemoveExistingFile: false,
logger: self.logger
)
if diskLog == nil {
self.logger.error(
"LogWriter.performInitialSetup: Unable to switch log. Log entries will not be written to disk."
)
}
self.workQueue.async {
if case .memory(let memoryLog) = self.logDestination {
let bytesWritten =
Self.writeMemoryLogToDisk(memoryLog: memoryLog, diskLog: diskLog, logger: self.logger)
self.fileSizeAddendum = UInt64(bytesWritten)
self.logDestination = .disk(diskLog)
self.logger.error(
"LogWriter.performInitialSetup: Switched to disk log."
)
}
}
}
}
private static func openDiskLog(
folderURL: URL, logFileNameBase: String,
logIndex: Int, shouldRemoveExistingFile: Bool, logger: Logger
) -> DiskLog? {
init?(category: AppLogger.Category, folderURL: URL?, logger: Logger) {
let fileManager = FileManager.default
let dateFormatter = ISO8601DateFormatter()
let jsonEncoder = JSONEncoder()
dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
jsonEncoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
// Look for an existing log file with this log index
var existingLogFiles: [URL] = []
if let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey, .nameKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants, .skipsSubdirectoryDescendants],
errorHandler: nil
) {
for item in enumerator.enumerated() {
guard let url = item.element as? URL else { continue }
do {
let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .nameKey])
if resourceValues.isRegularFile ?? false {
if let fileName = resourceValues.name {
if fileName.hasPrefix("\(logFileNameBase).")
&& fileName.hasSuffix(".\(logIndex).\(Self.logFileNameExtension)")
{
existingLogFiles.append(url)
}
}
}
} catch {
logger.error(
"LogWriter.openDiskLog: Unable to get resource value for '\(url.path, privacy: .public)': \(error, privacy: .public)"
)
}
}
}
self.dateFormatter = dateFormatter
self.jsonEncoder = jsonEncoder
self.logger = logger
self.category = category
if !shouldRemoveExistingFile && existingLogFiles.count > 0 {
// Open the existing log file in append mode
if existingLogFiles.count > 1 {
// In case there are multiple log files at this index (there shouldn't be),
// pick something predictably, so we pick the same one each time.
existingLogFiles.sort(by: { $0.lastPathComponent > $1.lastPathComponent })
}
let existingLogFile = existingLogFiles.first!
let existingLogFilePath = existingLogFile.path
logger.error(
"LogWriter.openDiskLog: File exists at '\(existingLogFilePath, privacy: .public)'"
)
var fileSize: UInt64? = nil
do {
let attr = try fileManager.attributesOfItem(atPath: existingLogFilePath)
fileSize = attr[FileAttributeKey.size] as? UInt64
} catch {
logger.error(
"LogWriter.openDiskLog: Error getting file attributes of '\(existingLogFilePath, privacy: .public)': \(error, privacy: .public)"
)
return nil
}
if let fileSize = fileSize {
if let filePointer = fopen(existingLogFilePath, "a") {
return DiskLog(logIndex: logIndex, filePointer: filePointer, fileSizeAtOpen: fileSize)
} else {
logger.error(
"LogWriter.openDiskLog: Can't open file '\(existingLogFilePath, privacy: .public)' for appending"
)
return nil
}
} else {
logger.error(
"LogWriter.openDiskLog: Can't figure out file size for '\(existingLogFilePath, privacy: .public)'"
)
return nil
}
}
if shouldRemoveExistingFile {
// Remove the existing log file
for existingLogFile in existingLogFiles {
// In case there are multiple log files at this index (there shouldn't be),
// remove all of them.
do {
logger.error(
"LogWriter.openDiskLog: Removing file at '\(existingLogFile.path, privacy: .public)'"
)
try fileManager.removeItem(at: existingLogFile)
} catch {
logger.error(
"LogWriter.openDiskLog: Error removing file '\(existingLogFile.path, privacy: .public)'"
)
}
}
}
// There's no log file at this log index. Create a new log file in write mode.
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss"
let timestamp = dateFormatter.string(from: Date())
let logFileURL = folderURL.appendingPathComponent(
"\(logFileNameBase).\(timestamp).\(logIndex).\(Self.logFileNameExtension)")
let logFilePath = logFileURL.path
logger.error("LogWriter.openDiskLog: Creating file at '\(logFilePath, privacy: .public)'")
if let filePointer = fopen(logFilePath, "w") {
return DiskLog(logIndex: logIndex, filePointer: filePointer, fileSizeAtOpen: 0)
} else {
logger.error(
"LogWriter.openDiskLog: Can't open file '\(logFilePath, privacy: .public)' for writing"
)
// Create log dir if not exists
guard let folderURL = folderURL,
SharedAccess.ensureDirectoryExists(at: folderURL.path)
else {
logger.error("Log directory isn't acceptable!")
return nil
}
self.logFileURL = folderURL
.appendingPathComponent(dateFormatter.string(from: Date()))
.appendingPathExtension("log")
// Create log file
guard fileManager.createFile(atPath: self.logFileURL.path, contents: "".data(using: .utf8))
else {
logger.error("Could not create log file: \(self.logFileURL.path)")
return nil
}
self.workQueue = DispatchQueue(label: "LogWriter.workQueue", qos: .utility)
}
func writeLogEntry(severity: Severity, message: String) {
func write(severity: Severity, message: String) {
let logEntry = LogEntry(
time: self.dateFormatter.string(from: Date()),
target: self.target,
time: dateFormatter.string(from: Date()),
category: category,
severity: severity,
message: message)
var jsonData = Data()
do {
jsonData = try jsonEncoder.encode(logEntry)
jsonData.append(self.newlineData)
} catch {
self.logger.error(
"LogWriter.writeLogEntry: Error encoding log entry to JSON: \(error, privacy: .public)"
)
guard var jsonData = try? jsonEncoder.encode(logEntry),
let newLineData = "\n".data(using: .utf8)
else {
logger.error("Could not encode log message to JSON!")
return
}
jsonData.append(newLineData)
workQueue.async {
switch self.logDestination {
case .disk(let diskLogNullable):
guard let diskLog = diskLogNullable else { return }
var bytesWritten = 0
do {
bytesWritten = try Self.writeDataToDisk(data: jsonData, diskLog: diskLog)
} catch {
self.logger.error(
"LogWriter.writeLogEntry: Error writing log entry to disk: \(error, privacy: .public)"
)
}
self.fileSizeAddendum += UInt64(bytesWritten)
if (diskLog.fileSizeAtOpen + self.fileSizeAddendum) > Self.maxLogFileSize {
let memoryLog = MemoryLog()
self.logDestination = .memory(memoryLog)
self.fileSizeAddendum = 0
self.logger.error("LogWriter.writeLogEntry: Temporarily using memory log")
self.diskLogSwitchingQueue.async {
fclose(diskLog.filePointer)
let nextLogIndex = (diskLog.logIndex + 1) % Self.maxLogFilesCount
let nextDiskLogNullable = Self.openDiskLog(
folderURL: self.folderURL, logFileNameBase: self.logFileNameBase,
logIndex: nextLogIndex,
shouldRemoveExistingFile: true, logger: self.logger
)
guard let nextDiskLog = nextDiskLogNullable else {
self.logger.error(
"LogWriter.writeLogEntry: Unable to switch log. Log entries will not be written to disk."
)
self.workQueue.async {
self.logDestination = .disk(nil)
}
return
}
do {
try "\(nextLogIndex)"
.write(to: self.currentIndexFileURL, atomically: true, encoding: .utf8)
} catch {
self.logger.error(
"LogWriter.writeLogEntry: Error writing current index as '\(nextLogIndex)' to disk: \(error, privacy: .public)"
)
return
}
self.workQueue.async {
let bytesWritten = Self.writeMemoryLogToDisk(
memoryLog: memoryLog, diskLog: nextDiskLog, logger: self.logger)
self.fileSizeAddendum = UInt64(bytesWritten)
self.logDestination = .disk(nextDiskLog)
self.logger.error("LogWriter.writeLogEntry: Switched to disk log")
}
}
}
case .memory(let memoryLog):
memoryLog.data.append(jsonData)
do {
try jsonData.write(to: self.logFileURL, options: .atomic)
} catch {
self.logger.error("Could not write LogEntry! \(error)")
}
}
}
private static func writeMemoryLogToDisk(memoryLog: MemoryLog, diskLog: DiskLog?, logger: Logger)
-> Int
{
guard let diskLog = diskLog else {
logger.error("LogWriter.writeMemoryLogToDisk: diskLog is nil")
return 0
}
guard memoryLog.data.count > 0 else {
return 0
}
do {
logger.log(
"LogWriter.writeMemoryLogToDisk: Writing memory log to disk (\(memoryLog.data.count, privacy: .public) bytes)"
)
return try writeDataToDisk(data: memoryLog.data, diskLog: diskLog)
} catch {
logger.error(
"LogWriter.writeMemoryLogToDisk: Error writing memory log to disk: \(error, privacy: .public)"
)
}
return 0
}
private static func writeDataToDisk(data: Data, diskLog: DiskLog) throws -> Int {
var bytesWritten = 0
if data.count > 0 {
bytesWritten = try data.withUnsafeBytes<Int> { pointer -> Int in
fwrite(
pointer.baseAddress, MemoryLayout<CChar>.size, data.count,
diskLog.filePointer
)
}
fflush(diskLog.filePointer)
}
return bytesWritten
}
}

View File

@@ -41,7 +41,7 @@ public class SessionNotificationHelper: NSObject {
}
private let logger: AppLogger
private let authStore: AuthStore
private let tunnelStore: TunnelStore
@Published var notificationDecision: NotificationDecision = .uninitialized {
didSet {
@@ -51,10 +51,10 @@ public class SessionNotificationHelper: NSObject {
}
}
public init(logger: AppLogger, authStore: AuthStore) {
public init(logger: AppLogger, tunnelStore: TunnelStore) {
self.logger = logger
self.authStore = authStore
self.tunnelStore = tunnelStore
super.init()
@@ -145,7 +145,7 @@ public class SessionNotificationHelper: NSObject {
#elseif os(macOS)
// In macOS, use a Cocoa alert.
// This gets called from the app side.
static func showSignedOutAlertmacOS(logger: AppLogger, authStore: AuthStore) {
static func showSignedOutAlertmacOS(logger: AppLogger, tunnelStore: TunnelStore) {
let alert = NSAlert()
alert.messageText = "Your Firezone session has ended"
alert.informativeText = "Please sign in again to reconnect"
@@ -157,7 +157,7 @@ public class SessionNotificationHelper: NSObject {
logger.log("SessionNotificationHelper: \(#function): 'Sign In' clicked in notification")
Task {
do {
try await authStore.signIn()
try await tunnelStore.signIn()
} catch {
logger.error("Error signing in: \(error)")
}
@@ -178,7 +178,7 @@ public class SessionNotificationHelper: NSObject {
// User clicked on 'Sign In' in the notification
Task {
do {
try await self.authStore.signIn()
try await tunnelStore.signIn()
} catch {
self.logger.error("Error signing in: \(error)")
}

View File

@@ -69,11 +69,11 @@ public struct SharedAccess {
return nil
}
public static var tunnelShutdownEventFileURL: URL {
baseFolderURL.appendingPathComponent("tunnel_shutdown_event_data.json")
public static var providerStopReasonURL: URL {
baseFolderURL.appendingPathComponent("reason")
}
private static func ensureDirectoryExists(at path: String) -> Bool {
public static func ensureDirectoryExists(at path: String) -> Bool {
let fileManager = FileManager.default
do {
var isDirectory: ObjCBool = false

View File

@@ -1,117 +0,0 @@
//
// DisconnectReason.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Foundation
import NetworkExtension
import os
enum TunnelShutdownEventError: Error {
case decodeError
case cannotGetFileURL
}
public struct TunnelShutdownEvent: Codable, CustomStringConvertible {
public enum Reason: Codable, CustomStringConvertible {
case stopped(NEProviderStopReason)
case connlibConnectFailure
case connlibDisconnected
case badTunnelConfiguration
case tokenNotFound
public var description: String {
switch self {
case .stopped(let reason): return "stopped(reason code: \(reason.rawValue))"
case .connlibConnectFailure: return "connlib connection failure"
case .connlibDisconnected: return "connlib disconnected"
case .badTunnelConfiguration: return "bad tunnel configuration"
case .tokenNotFound: return "token not found"
}
}
public var action: Action {
switch self {
case .stopped(let reason):
if reason == .userInitiated {
return .signoutImmediatelySilently
} else {
return .doNothing
}
case .connlibConnectFailure, .connlibDisconnected,
.badTunnelConfiguration, .tokenNotFound:
return .signoutImmediately
}
}
}
public enum Action {
case doNothing
case signoutImmediately
case signoutImmediatelySilently
}
public let reason: TunnelShutdownEvent.Reason
public let errorMessage: String
public let date: Date
public var action: Action { reason.action }
public var description: String {
"(\(reason)\(action == .signoutImmediately ? " (needs immediate signout)" : ""), error: '\(errorMessage)', date: \(date))"
}
public init(reason: TunnelShutdownEvent.Reason, errorMessage: String) {
self.reason = reason
self.errorMessage = errorMessage
self.date = Date()
}
public static func loadFromDisk(logger: AppLogger) -> TunnelShutdownEvent? {
let fileURL = SharedAccess.tunnelShutdownEventFileURL
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: fileURL.path) else {
return nil
}
guard let jsonData = try? Data(contentsOf: fileURL) else {
logger.error("Could not read tunnel shutdown event from disk at: \(fileURL)")
return nil
}
guard let reason = try? JSONDecoder().decode(TunnelShutdownEvent.self, from: jsonData) else {
logger.error("Error decoding tunnel shutdown event from disk at: \(fileURL)")
return nil
}
do {
try fileManager.removeItem(atPath: fileURL.path)
} catch {
logger.error("Cannot remove tunnel shutdown event file at \(fileURL.path)")
}
return reason
}
public static func saveToDisk(
reason: TunnelShutdownEvent.Reason, errorMessage: String, logger: AppLogger
) {
let fileURL = SharedAccess.tunnelShutdownEventFileURL
logger.error("Saving tunnel shutdown event data to \(fileURL)")
let tsEvent = TunnelShutdownEvent(
reason: reason,
errorMessage: errorMessage)
do {
try JSONEncoder().encode(tsEvent).write(to: fileURL)
} catch {
logger.error(
"Error writing tunnel shutdown event data to disk to: \(fileURL): \(error)"
)
}
}
}
extension NEProviderStopReason: Codable {
}

View File

@@ -7,7 +7,7 @@
import Foundation
public enum KeychainError: Error {
case securityError(Status)
case securityError(KeychainStatus)
case appleSecError(call: String, status: Keychain.SecStatus)
case nilResultFromAppleSecCall(call: String)
case resultFromAppleSecCallIsInvalid(call: String)
@@ -18,23 +18,22 @@ public enum KeychainError: Error {
}
public actor Keychain {
private static let account = "Firezone"
private let label = "Firezone token"
private let description = "Firezone access token used to authenticate the client."
private let account = "Firezone"
private let service = Bundle.main.bundleIdentifier!
private let workQueue = DispatchQueue(label: "FirezoneKeychainWorkQueue")
public typealias Token = String
public typealias PersistentRef = Data
public struct TokenAttributes {
let authBaseURLString: String
let actorName: String
}
public enum SecStatus: Equatable {
case status(Status)
case status(KeychainStatus)
case unknownStatus(OSStatus)
init(_ osStatus: OSStatus) {
if let status = Status(rawValue: osStatus) {
if let status = KeychainStatus(rawValue: osStatus) {
self = .status(status)
} else {
self = .unknownStatus(osStatus)
@@ -48,42 +47,27 @@ public actor Keychain {
public init() {}
func store(token: Token, tokenAttributes: TokenAttributes) async throws -> PersistentRef {
#if os(iOS)
let query =
[
// Common for both iOS and macOS:
kSecClass: kSecClassGenericPassword,
kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))",
kSecAttrDescription: "Firezone access token",
kSecAttrService: tokenAttributes.authBaseURLString,
// The UUID uniquifies this item in the keychain
kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)",
kSecValueData: token.data(using: .utf8) as Any,
kSecReturnPersistentRef: true,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
// Specific to iOS:
kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any,
] as [CFString: Any]
#elseif os(macOS)
let query =
[
// Common for both iOS and macOS:
kSecClass: kSecClassGenericPassword,
kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))",
kSecAttrDescription: "Firezone access token",
kSecAttrService: tokenAttributes.authBaseURLString,
// The UUID uniquifies this item in the keychain
kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)",
kSecValueData: token.data(using: .utf8) as Any,
kSecReturnPersistentRef: true,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
// Specific to macOS:
kSecAttrAccess: try secAccessForAppAndNetworkExtension(),
] as [CFString: Any]
#endif
func add(token: Token) async throws -> PersistentRef {
return try await withCheckedThrowingContinuation { [weak self] continuation in
self?.workQueue.async {
self?.workQueue.async { [weak self] in
guard let self = self else {
continuation.resume(throwing: KeychainError.securityError(.unexpectedError))
return
}
let query: [CFString: Any] = [
// Common for both iOS and macOS:
kSecClass: kSecClassGenericPassword,
kSecAttrLabel: label,
kSecAttrDescription: description,
kSecAttrAccount: account,
kSecAttrService: service,
kSecValueData: token.data(using: .utf8) as Any,
kSecReturnPersistentRef: true,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any,
]
var ref: CFTypeRef?
let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref))
guard ret.isSuccess else {
@@ -91,85 +75,45 @@ public actor Keychain {
throwing: KeychainError.appleSecError(call: "SecItemAdd", status: ret))
return
}
guard let savedPersistentRef = ref as? Data else {
continuation.resume(throwing: KeychainError.nilResultFromAppleSecCall(call: "SecItemAdd"))
return
}
// Remove any other keychain items for the same service URL
var checkForStaleItemsResult: CFTypeRef?
let checkForStaleItemsQuery =
[
kSecClass: kSecClassGenericPassword,
kSecAttrService: tokenAttributes.authBaseURLString,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnPersistentRef: true,
] as [CFString: Any]
let checkRet =
SecStatus(
SecItemCopyMatching(checkForStaleItemsQuery as CFDictionary, &checkForStaleItemsResult))
var isSavedItemFound = false
if checkRet.isSuccess, let allRefs = checkForStaleItemsResult as? [Data] {
for ref in allRefs {
if ref == savedPersistentRef {
isSavedItemFound = true
} else {
SecItemDelete([kSecValuePersistentRef: ref] as CFDictionary)
}
}
}
guard isSavedItemFound else {
continuation.resume(throwing: KeychainError.unableToFindSavedItem)
return
}
continuation.resume(returning: savedPersistentRef)
return
}
}
}
#if os(macOS)
private func secAccessForAppAndNetworkExtension() throws -> SecAccess {
// Creating a trusted-application-based SecAccess APIs are deprecated in favour of
// data-protection keychain APIs. However, data-protection keychain doesn't support
// accessing from non-userspace processes, like the tunnel process, so we can only
// use the deprecated APIs for now.
func secTrustedApplicationForPath(_ path: String?) throws -> SecTrustedApplication? {
var trustedApp: SecTrustedApplication?
let ret = SecStatus(SecTrustedApplicationCreateFromPath(path, &trustedApp))
func update(token: Token) async throws {
return try await withCheckedThrowingContinuation { [weak self] continuation in
self?.workQueue.async { [weak self] in
guard let self = self else {
continuation.resume(throwing: KeychainError.securityError(.unexpectedError))
return
}
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: self.account,
kSecAttrService: self.service,
]
let attributesToUpdate = [
kSecValueData: token.data(using: .utf8) as Any
]
let ret = SecStatus(
SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary))
guard ret.isSuccess else {
throw KeychainError.appleSecError(
call: "SecTrustedApplicationCreateFromPath", status: ret)
continuation.resume(
throwing: KeychainError.appleSecError(call: "SecItemUpdate", status: ret))
return
}
if let trustedApp = trustedApp {
return trustedApp
} else {
throw KeychainError.nilResultFromAppleSecCall(
call: "SecTrustedApplicationCreateFromPath(\(path ?? "nil"))")
}
}
guard let pluginsURL = Bundle.main.builtInPlugInsURL else {
throw KeychainError.unableToGetPluginsPath
}
let extensionPath =
pluginsURL
.appendingPathComponent("FirezoneNetworkExtensionmacOS.appex", isDirectory: true)
.path
let trustedApps = [
try secTrustedApplicationForPath(nil),
try secTrustedApplicationForPath(extensionPath),
]
var access: SecAccess?
let ret = SecStatus(
SecAccessCreate("Firezone Token" as CFString, trustedApps as CFArray, &access))
guard ret.isSuccess else {
throw KeychainError.appleSecError(call: "SecAccessCreate", status: ret)
}
if let access = access {
return access
} else {
throw KeychainError.nilResultFromAppleSecCall(call: "SecAccessCreate")
continuation.resume()
}
}
#endif
}
// This function is public because the tunnel needs to call it to get the token
public func load(persistentRef: PersistentRef) async -> Token? {
@@ -194,64 +138,16 @@ public actor Keychain {
}
}
func loadAttributes(persistentRef: PersistentRef) async -> TokenAttributes? {
func search() async -> PersistentRef? {
return await withCheckedContinuation { [weak self] continuation in
self?.workQueue.async {
let query =
[
kSecValuePersistentRef: persistentRef,
kSecReturnAttributes: true,
] as [CFString: Any]
var result: CFTypeRef?
let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
if ret.isSuccess, let result = result {
if CFGetTypeID(result) == CFDictionaryGetTypeID() {
let cfDict = result as! CFDictionary
let dict = cfDict as NSDictionary
if let service = dict[kSecAttrService] as? String,
let account = dict[kSecAttrAccount] as? String
{
let actorName = String(
account[
account
.startIndex..<(account.lastIndex(of: ":")
?? account.endIndex)])
let attributes = TokenAttributes(
authBaseURLString: service,
actorName: actorName)
continuation.resume(returning: attributes)
return
}
}
}
continuation.resume(returning: nil)
}
}
}
func delete(persistentRef: PersistentRef) async throws {
return try await withCheckedThrowingContinuation { [weak self] continuation in
self?.workQueue.async {
let query = [kSecValuePersistentRef: persistentRef] as [CFString: Any]
let ret = SecStatus(SecItemDelete(query as CFDictionary))
guard ret.isSuccess || ret == .status(.itemNotFound) else {
continuation.resume(
throwing: KeychainError.appleSecError(call: "SecItemDelete", status: ret))
return
}
continuation.resume(returning: ())
}
}
}
func search(authBaseURLString: String) async -> PersistentRef? {
return await withCheckedContinuation { [weak self] continuation in
self?.workQueue.async {
guard let self = self else { return }
self.workQueue.async {
let query =
[
kSecClass: kSecClassGenericPassword,
kSecAttrDescription: "Firezone access token",
kSecAttrService: authBaseURLString,
kSecAttrAccount: self.account,
kSecAttrDescription: self.description,
kSecAttrService: self.service,
kSecReturnPersistentRef: true,
] as [CFString: Any]
var result: CFTypeRef?
@@ -266,6 +162,6 @@ public actor Keychain {
}
private func securityError(_ status: OSStatus) -> Error {
KeychainError.securityError(Status(rawValue: status)!)
KeychainError.securityError(KeychainStatus(rawValue: status)!)
}
}

View File

@@ -10,7 +10,7 @@
import Foundation
public enum Status: OSStatus, Error {
public enum KeychainStatus: OSStatus, Error {
case success = 0
case unimplemented = -4
case diskFull = -34
@@ -416,12 +416,12 @@ public enum Status: OSStatus, Error {
case unexpectedError = -99999
}
extension Status {
static func == (lhs: OSStatus, rhs: Status) -> Bool {
extension KeychainStatus {
static func == (lhs: OSStatus, rhs: KeychainStatus) -> Bool {
lhs == rhs.rawValue
}
static func != (lhs: OSStatus, rhs: Status) -> Bool {
static func != (lhs: OSStatus, rhs: KeychainStatus) -> Bool {
lhs != rhs.rawValue
}
}

View File

@@ -8,11 +8,9 @@ import Dependencies
import Foundation
struct KeychainStorage: Sendable {
var store:
@Sendable (Keychain.Token, Keychain.TokenAttributes) async throws -> Keychain.PersistentRef
var delete: @Sendable (Keychain.PersistentRef) async throws -> Void
var loadAttributes: @Sendable (Keychain.PersistentRef) async -> Keychain.TokenAttributes?
var searchByAuthBaseURL: @Sendable (URL) async -> Keychain.PersistentRef?
var add: @Sendable (Keychain.Token) async throws -> Keychain.PersistentRef
var update: @Sendable (Keychain.Token) async throws -> Void
var search: @Sendable () async -> Keychain.PersistentRef?
}
extension KeychainStorage: DependencyKey {
@@ -20,33 +18,27 @@ extension KeychainStorage: DependencyKey {
let keychain = Keychain()
return KeychainStorage(
store: { try await keychain.store(token: $0, tokenAttributes: $1) },
delete: { try await keychain.delete(persistentRef: $0) },
loadAttributes: { await keychain.loadAttributes(persistentRef: $0) },
searchByAuthBaseURL: { await keychain.search(authBaseURLString: $0.absoluteString) }
add: { try await keychain.add(token: $0) },
update: { try await keychain.update(token: $0) },
search: { await keychain.search() }
)
}
static var testValue: KeychainStorage {
let storage = LockIsolated([Data: (Keychain.Token, Keychain.TokenAttributes)]())
let storage = LockIsolated([Data: (Keychain.Token)]())
return KeychainStorage(
store: { token, attributes in
add: { token in
storage.withValue {
let uuid = UUID().uuidString.data(using: .utf8)!
$0[uuid] = (token, attributes)
$0[uuid] = (token)
return uuid
}
},
delete: { ref in
storage.withValue {
$0[ref] = nil
}
update: { token in
},
loadAttributes: { ref in
storage.value[ref]?.1
},
searchByAuthBaseURL: { _ in
nil
search: {
return UUID().uuidString.data(using: .utf8)!
}
)
}

View File

@@ -1,75 +0,0 @@
//
// DisplayableResources.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
// This models resources that are displayed in the UI
import Foundation
public class DisplayableResources {
public struct Resource: Identifiable {
public var id: String { location }
public let name: String
public let location: String
public init(name: String, location: String) {
self.name = name
self.location = location
}
}
public private(set) var version: UInt64
public private(set) var versionString: String
public private(set) var orderedResources: [Resource]
public init(version: UInt64, resources: [Resource]) {
self.version = version
self.versionString = "\(version)"
self.orderedResources = resources
}
public convenience init() {
self.init(version: 0, resources: [])
}
public func update(resources: [Resource]) {
self.version = self.version &+ 1 // Overflow is ok
self.versionString = "\(version)"
self.orderedResources = resources
}
}
extension DisplayableResources {
public func toData() -> Data? {
("\(versionString),"
+ (orderedResources.flatMap { [$0.name, $0.location] })
.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) }.compactMap { $0 }
.joined(separator: ",")).data(using: .utf8)
}
public convenience init?(from data: Data) {
guard let components = String(data: data, encoding: .utf8)?.split(separator: ",") else {
return nil
}
guard let versionString = components.first, let version = UInt64(versionString) else {
return nil
}
var resources: [Resource] = []
for index in stride(from: 2, to: components.count, by: 2) {
guard let name = components[index - 1].removingPercentEncoding,
let location = components[index].removingPercentEncoding
else {
continue
}
resources.append(Resource(name: name, location: location))
}
self.init(version: version, resources: resources)
}
public func versionStringToData() -> Data {
versionString.data(using: .utf8)!
}
}

View File

@@ -1,10 +0,0 @@
//
// FirezoneError.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Foundation
enum FirezoneError: Error {
}

View File

@@ -0,0 +1,23 @@
//
// Resource.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
// This models resources that are displayed in the UI
import Foundation
public struct Resource: Decodable, Identifiable {
public let id: String
public var name: String
public var address: String
public var type: String
public init(id: String, name: String, address: String, type: String) {
self.id = id
self.name = name
self.address = address
self.type = type
}
}

View File

@@ -6,48 +6,72 @@
import Foundation
struct AdvancedSettings: Equatable {
var authBaseURLString: String {
didSet { if oldValue != authBaseURLString { isSavedToDisk = false } }
}
var apiURLString: String {
didSet { if oldValue != apiURLString { isSavedToDisk = false } }
}
var connlibLogFilterString: String {
didSet { if oldValue != connlibLogFilterString { isSavedToDisk = false } }
}
var isSavedToDisk = true
struct Settings: Equatable {
var authBaseURL: String
var apiURL: String
var logFilter: String
var isValid: Bool {
URL(string: authBaseURLString) != nil
&& URL(string: apiURLString) != nil
&& !connlibLogFilterString.isEmpty
let authBaseURL = URL(string: authBaseURL)
let apiURL = URL(string: apiURL)
// Technically strings like "foo" are valid URLs, but their host component
// would be nil which crashes the ASWebAuthenticationSession view when
// signing in. We should also validate the scheme, otherwise ftp://
// could be used for example which tries to open the Finder when signing
// in. 🙃
return authBaseURL?.host != nil
&& apiURL?.host != nil
&& ["http", "https"].contains(authBaseURL?.scheme)
&& ["ws", "wss"].contains(apiURL?.scheme)
&& !logFilter.isEmpty
}
static let defaultValue: AdvancedSettings = {
// Convert provider configuration (which may have empty fields if it was tampered with) to Settings
static func fromProviderConfiguration(providerConfiguration: [String: String]?) -> Settings {
if let providerConfiguration = providerConfiguration {
return Settings(
authBaseURL: providerConfiguration[TunnelStoreKeys.authBaseURL]
?? Settings.defaultValue.authBaseURL,
apiURL: providerConfiguration[TunnelStoreKeys.apiURL] ?? Settings.defaultValue.apiURL,
logFilter: providerConfiguration[TunnelStoreKeys.logFilter]
?? Settings.defaultValue.logFilter
)
} else {
return Settings.defaultValue
}
}
// Used for initializing a new providerConfiguration from Settings
func toProviderConfiguration() -> [String: String] {
return [
"authBaseURL": authBaseURL,
"apiURL": apiURL,
"logFilter": logFilter,
]
}
static let defaultValue: Settings = {
// Note: To see what the connlibLogFilterString values mean, see:
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html
#if DEBUG
AdvancedSettings(
authBaseURLString: "https://app.firez.one/",
apiURLString: "wss://api.firez.one/",
connlibLogFilterString:
"firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,str0m=info,debug"
Settings(
authBaseURL: "https://app.firez.one",
apiURL: "wss://api.firez.one",
logFilter:
"firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn"
)
#else
AdvancedSettings(
authBaseURLString: "https://app.firezone.dev/",
apiURLString: "wss://api.firezone.dev/",
connlibLogFilterString: "info"
)
Settings(
authBaseURL: "https://app.firezone.dev",
apiURL: "wss://api.firezone.dev",
logFilter: "info"
)
#endif
}()
// Note: To see what the connlibLogFilterString values mean, see:
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html
}
extension AdvancedSettings: CustomStringConvertible {
extension Settings: CustomStringConvertible {
var description: String {
"(\(authBaseURLString), \(apiURLString), \(connlibLogFilterString))"
"(\(authBaseURL), \(apiURL), \(logFilter)"
}
}

View File

@@ -54,14 +54,9 @@ public final class AppStore: ObservableObject {
return false
}
}
public static func allIdentifiers() -> [String] {
AppStore.WindowDefinition.allCases.map { $0.identifier }
}
}
#endif
public let authStore: AuthStore
public let tunnelStore: TunnelStore
public let settingsViewModel: SettingsViewModel
@@ -69,41 +64,29 @@ public final class AppStore: ObservableObject {
public let logger: AppLogger
public init() {
let logger = AppLogger(process: .app, folderURL: SharedAccess.appLogFolderURL)
let logger = AppLogger(category: .app, folderURL: SharedAccess.appLogFolderURL)
let tunnelStore = TunnelStore(logger: logger)
let authStore = AuthStore(tunnelStore: tunnelStore, logger: logger)
let settingsViewModel = SettingsViewModel(authStore: authStore, logger: logger)
let settingsViewModel = SettingsViewModel(tunnelStore: tunnelStore, logger: logger)
self.authStore = authStore
self.tunnelStore = tunnelStore
self.settingsViewModel = settingsViewModel
self.logger = logger
#if os(macOS)
tunnelStore.$tunnelAuthStatus
.sink { tunnelAuthStatus in
if case .noTunnelFound = tunnelAuthStatus {
Task {
await MainActor.run {
tunnelStore.$status
.sink { status in
Task {
await MainActor.run {
// FIXME: Clean up Swift UI window groups to use a multi-step wizard
if case .invalid = status {
WindowDefinition.askPermission.openWindow()
} else if !tunnelStore.firstTime() {
WindowDefinition.askPermission.window()?.close()
}
}
}
}
.store(in: &cancellables)
#endif
}
private func signOutAndStopTunnel() {
Task {
do {
try await self.tunnelStore.stop()
await self.authStore.signOut()
} catch {
logger.error("\(#function): Error stopping tunnel: \(String(describing: error))")
}
}
}
}

View File

@@ -1,257 +0,0 @@
//
// AuthStore.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Combine
import Dependencies
import Foundation
import NetworkExtension
import OSLog
#if os(macOS)
import AppKit
#endif
@MainActor
public final class AuthStore: ObservableObject {
enum LoginStatus: CustomStringConvertible {
case uninitialized
case needsTunnelCreationPermission
case signedOut
case signedIn(actorName: String)
var description: String {
switch self {
case .uninitialized:
return "uninitialized"
case .needsTunnelCreationPermission:
return "needsTunnelCreationPermission"
case .signedOut:
return "signedOut"
case .signedIn(let actorName):
return "signedIn(actorName: \(actorName))"
}
}
}
@Dependency(\.keychain) private var keychain
@Dependency(\.auth) private var auth
@Dependency(\.mainQueue) private var mainQueue
let tunnelStore: TunnelStore
private let logger: AppLogger
private var cancellables = Set<AnyCancellable>()
@Published private(set) var loginStatus: LoginStatus {
didSet {
self.handleLoginStatusChanged()
}
}
private var status: NEVPNStatus = .invalid
// Try to automatically reconnect on network changes
private static let maxReconnectionAttemptCount = 60
private let reconnectDelaySecs = 1
private var reconnectionAttemptsRemaining = maxReconnectionAttemptCount
init(tunnelStore: TunnelStore, logger: AppLogger) {
self.tunnelStore = tunnelStore
self.logger = logger
self.loginStatus = .uninitialized
Task {
self.loginStatus = await self.getLoginStatus(from: tunnelStore.tunnelAuthStatus)
}
tunnelStore.$tunnelAuthStatus
.receive(on: mainQueue)
.sink { [weak self] tunnelAuthStatus in
guard let self = self else { return }
logger.log("Tunnel auth status changed to: \(tunnelAuthStatus)")
self.upateLoginStatus()
}
.store(in: &cancellables)
tunnelStore.$status
.sink { [weak self] status in
guard let self = self else { return }
Task {
if status == .disconnected {
self.handleTunnelDisconnectionEvent()
}
self.status = status
}
}
.store(in: &cancellables)
}
private var authBaseURL: URL {
if let advancedSettings = self.tunnelStore.advancedSettings(),
let url = URL(string: advancedSettings.authBaseURLString)
{
return url
}
return URL(string: AdvancedSettings.defaultValue.authBaseURLString)!
}
private func upateLoginStatus() {
Task {
logger.log("\(#function): Tunnel auth status is \(self.tunnelStore.tunnelAuthStatus)")
let tunnelAuthStatus = tunnelStore.tunnelAuthStatus
let loginStatus = await self.getLoginStatus(from: tunnelAuthStatus)
if tunnelAuthStatus != self.tunnelStore.tunnelAuthStatus {
// The tunnel auth status has changed while we were getting the
// login status, so this login status is not to be used.
logger.log("\(#function): Ignoring login status \(loginStatus) that's no longer valid.")
return
}
logger.log("\(#function): Corresponding login status is \(loginStatus)")
await MainActor.run {
self.loginStatus = loginStatus
}
}
}
private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus {
switch tunnelAuthStatus {
case .uninitialized:
return .uninitialized
case .noTunnelFound:
return .needsTunnelCreationPermission
case .signedOut:
return .signedOut
case .signedIn(let tunnelAuthBaseURL, let tokenReference):
guard self.authBaseURL == tunnelAuthBaseURL else {
return .signedOut
}
let tunnelBaseURLString = self.authBaseURL.absoluteString
guard let tokenAttributes = await keychain.loadAttributes(tokenReference),
tunnelBaseURLString == tokenAttributes.authBaseURLString
else {
return .signedOut
}
return .signedIn(actorName: tokenAttributes.actorName)
}
}
func signIn() async throws {
logger.log("\(#function)")
let authResponse = try await auth.signIn(self.authBaseURL)
let attributes = Keychain.TokenAttributes(
authBaseURLString: self.authBaseURL.absoluteString, actorName: authResponse.actorName ?? "")
let tokenRef = try await keychain.store(authResponse.token, attributes)
try await tunnelStore.saveAuthStatus(
.signedIn(authBaseURL: self.authBaseURL, tokenReference: tokenRef))
}
func signOut() async {
logger.log("\(#function)")
guard case .signedIn = self.tunnelStore.tunnelAuthStatus else {
logger.log("\(#function): Not signed in, so can't signout.")
return
}
do {
try await tunnelStore.stop()
if let tokenRef = try await tunnelStore.signOut() {
try await keychain.delete(tokenRef)
}
} catch {
logger.error("\(#function): Error signing out: \(error)")
}
}
public func cancelSignIn() {
auth.cancelSignIn()
}
func startTunnel() {
logger.log("\(#function)")
guard case .signedIn = self.tunnelStore.tunnelAuthStatus else {
logger.log("\(#function): Not signed in, so can't start the tunnel.")
return
}
Task {
do {
try await tunnelStore.start()
} catch {
if case TunnelStoreError.startTunnelErrored(let startTunnelError) = error {
logger.error(
"\(#function): Starting tunnel errored: \(String(describing: startTunnelError))"
)
handleTunnelDisconnectionEvent()
} else {
logger.error("\(#function): Starting tunnel failed: \(String(describing: error))")
// Disconnection event will be handled in the tunnel status change handler
}
}
}
}
func handleTunnelDisconnectionEvent() {
logger.log("\(#function)")
if let tsEvent = TunnelShutdownEvent.loadFromDisk(logger: logger) {
self.logger.log(
"\(#function): Tunnel shutdown event: \(tsEvent)"
)
switch tsEvent.action {
case .signoutImmediately:
Task {
await self.signOut()
}
#if os(macOS)
SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, authStore: self)
#endif
case .signoutImmediatelySilently:
Task {
await self.signOut()
}
case .doNothing:
break
}
} else {
self.logger.log("\(#function): Tunnel shutdown event not found")
}
}
private func handleLoginStatusChanged() {
logger.log("\(#function): Login status: \(self.loginStatus)")
switch self.loginStatus {
case .signedIn:
self.startTunnel()
case .signedOut:
Task {
do {
try await tunnelStore.stop()
} catch {
logger.error("\(#function): Error stopping tunnel: \(String(describing: error))")
}
if tunnelStore.tunnelAuthStatus != .signedOut {
// Bring tunnelAuthStatus in line, in case it's out of touch with the login status
try await tunnelStore.saveAuthStatus(.signedOut)
}
}
case .needsTunnelCreationPermission:
break
case .uninitialized:
break
}
}
func tunnelAuthStatus(for authBaseURL: URL) async -> TunnelAuthStatus {
if let tokenRef = await keychain.searchByAuthBaseURL(authBaseURL) {
return .signedIn(authBaseURL: authBaseURL, tokenReference: tokenRef)
} else {
return .signedOut
}
}
}

View File

@@ -5,33 +5,45 @@
//
import Combine
import CryptoKit
import Dependencies
import Foundation
import NetworkExtension
import OSLog
#if os(macOS)
import AppKit
#endif
enum TunnelStoreError: Error {
case tunnelCouldNotBeStarted
case tunnelCouldNotBeStopped
case cannotSaveToTunnelWhenConnected
case cannotSaveWhenConnected
case cannotSaveIfMissing
case cannotSignOutWhenConnected
case stopAlreadyBeingAttempted
case startTunnelErrored(Error)
}
public struct TunnelProviderKeys {
static let keyAuthBaseURLString = "authBaseURLString"
public static let keyConnlibLogFilter = "connlibLogFilter"
public struct TunnelStoreKeys {
static let actorName = "actorName"
static let authBaseURL = "authBaseURL"
static let apiURL = "apiURL"
public static let logFilter = "logFilter"
}
/// A utility class for managing our VPN profile in System Preferences
public final class TunnelStore: ObservableObject {
@Published private var tunnel: NETunnelProviderManager?
@Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .uninitialized
// Make our tunnel configuration convenient for SettingsView to consume
@Published private(set) var settings: Settings
@Published private(set) var status: NEVPNStatus {
didSet { self.logger.log("status changed: \(self.status.description)") }
}
// Enacapsulate Tunnel status here to make it easier for other components
// to observe
@Published private(set) var status: NEVPNStatus
@Published private(set) var resources = DisplayableResources()
@Published private(set) var resourceListJSON: String?
public var manager: NETunnelProviderManager?
private var resourcesTimer: Timer? {
didSet(oldValue) { oldValue?.invalidate() }
@@ -39,186 +51,211 @@ public final class TunnelStore: ObservableObject {
private let logger: AppLogger
private var tunnelObservingTasks: [Task<Void, Never>] = []
private var startTunnelContinuation: CheckedContinuation<(), Error>?
private var stopTunnelContinuation: CheckedContinuation<(), Error>?
private var cancellables = Set<AnyCancellable>()
// Use separate bundle IDs for release and debug. Helps with testing releases
// and dev builds on the same Mac.
#if DEBUG
private let bundleIdentifier = Bundle.main.bundleIdentifier.map {
"\($0).debug.network-extension"
}
private let bundleDescription = "Firezone (Debug)"
#else
private let bundleIdentifier = Bundle.main.bundleIdentifier.map { "\($0).network-extension" }
private let bundleDescription = "Firezone"
#endif
@Dependency(\.keychain) private var keychain
@Dependency(\.auth) private var auth
@Dependency(\.mainQueue) private var mainQueue
public init(logger: AppLogger) {
self.tunnel = nil
self.tunnelAuthStatus = .uninitialized
self.status = .invalid
self.logger = logger
self.status = .disconnected
self.manager = nil
self.settings = Settings.defaultValue
// Connect UI state updates to this manager's status
setupTunnelObservers()
Task {
await initializeTunnel()
setupTunnelObservers()
}
}
// loadAllFromPreferences() returns list of tunnel configurations we created. Since our bundle ID
// can change (by us), find the one that's current and ignore the others.
let managers = try! await NETunnelProviderManager.loadAllFromPreferences()
logger.log("\(#function): \(managers.count) tunnel managers found")
for manager in managers {
if let protocolConfiguration = (manager.protocolConfiguration as? NETunnelProviderProtocol),
protocolConfiguration.providerBundleIdentifier == bundleIdentifier
{
self.settings = Settings.fromProviderConfiguration(
providerConfiguration: protocolConfiguration.providerConfiguration as? [String: String])
self.manager = manager
self.status = manager.connection.status
func initializeTunnel() async {
// loadAllFromPreferences() returns list of tunnel configurations we created. Since our bundle ID
// can change (by us), find the one that's current and ignore the others.
let managers = try! await NETunnelProviderManager.loadAllFromPreferences()
logger.log("\(#function): \(managers.count) tunnel managers found")
for manager in managers {
if let protocolConfig = (manager.protocolConfiguration as? NETunnelProviderProtocol),
protocolConfig.providerBundleIdentifier == NETunnelProviderManager.bundleIdentifier() {
self.tunnel = manager
self.tunnelAuthStatus = manager.authStatus()
self.status = manager.connection.status
// Stop looking for our tunnel
break
// Stop looking for our tunnel
break
}
}
self.tunnelAuthStatus = .noTunnelFound
// Connect on app launch unless we're already connected
if let _ = manager?.protocolConfiguration?.passwordReference,
self.status == .disconnected
{
try await start()
}
// If we haven't found a manager by this point, consider our status invalid
// to prompt creating one.
if manager == nil {
self.status = .invalid
}
}
}
func createTunnel() async throws {
guard self.tunnel == nil else {
return
}
let tunnel = NETunnelProviderManager()
tunnel.localizedDescription = NETunnelProviderManager.bundleDescription()
tunnel.protocolConfiguration = basicProviderProtocol()
try await tunnel.saveToPreferences()
logger.log("\(#function): Tunnel created")
self.tunnel = tunnel
self.tunnelAuthStatus = tunnel.authStatus()
// If firezone-id hasn't ever been written, the app is considered
// to be launched for the first time.
func firstTime() -> Bool {
let fileExists = FileManager.default.fileExists(
atPath: SharedAccess.baseFolderURL.appendingPathComponent("firezone-id").path
)
return !fileExists
}
func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws {
logger.log("TunnelStore.\(#function) \(tunnelAuthStatus)")
guard let tunnel = tunnel else {
fatalError("No tunnel yet. Can't save auth status.")
// Initialize and save a new VPN profile in system Preferences
func createManager() async throws {
if let manager = manager {
// Someone deleted the manager while Fireone is running!
// Let's assume that was an accident and recreate it from
// our current state.
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
} else {
let protocolConfiguration = NETunnelProviderProtocol()
let manager = NETunnelProviderManager()
let providerConfiguration =
protocolConfiguration.providerConfiguration
as? [String: String]
?? Settings.defaultValue.toProviderConfiguration()
protocolConfiguration.providerConfiguration = providerConfiguration
protocolConfiguration.providerBundleIdentifier = bundleIdentifier
protocolConfiguration.serverAddress = providerConfiguration[TunnelStoreKeys.apiURL]
manager.localizedDescription = bundleDescription
manager.protocolConfiguration = protocolConfiguration
// Save the new VPN profile to System Preferences
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
self.manager = manager
self.status = .disconnected
}
let tunnelStatus = tunnel.connection.status
if tunnelStatus == .connected || tunnelStatus == .connecting {
throw TunnelStoreError.cannotSaveToTunnelWhenConnected
}
try await tunnel.loadFromPreferences()
try await tunnel.saveAuthStatus(tunnelAuthStatus)
self.tunnelAuthStatus = tunnelAuthStatus
}
func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws {
logger.log("TunnelStore.\(#function) \(advancedSettings)")
guard let tunnel = tunnel else {
fatalError("No tunnel yet. Can't save advanced settings.")
}
let tunnelStatus = tunnel.connection.status
if tunnelStatus == .connected || tunnelStatus == .connecting {
throw TunnelStoreError.cannotSaveToTunnelWhenConnected
}
try await tunnel.loadFromPreferences()
try await tunnel.saveAdvancedSettings(advancedSettings)
self.tunnelAuthStatus = tunnel.authStatus()
}
func advancedSettings() -> AdvancedSettings? {
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
return nil
}
return tunnel.advancedSettings()
}
func basicProviderProtocol() -> NETunnelProviderProtocol {
let protocolConfiguration = NETunnelProviderProtocol()
protocolConfiguration.providerBundleIdentifier = NETunnelProviderManager.bundleIdentifier()
protocolConfiguration.serverAddress = AdvancedSettings.defaultValue.apiURLString
protocolConfiguration.providerConfiguration = [
TunnelProviderKeys.keyConnlibLogFilter:
AdvancedSettings.defaultValue.connlibLogFilterString
]
return protocolConfiguration
}
func start() async throws {
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
return
}
logger.log("\(#function)")
if tunnel.connection.status == .connected || tunnel.connection.status == .connecting {
guard let manager = manager
else {
logger.error("\(#function): No manager created yet")
return
}
if tunnel.advancedSettings().connlibLogFilterString.isEmpty {
tunnel.setConnlibLogFilter(AdvancedSettings.defaultValue.connlibLogFilterString)
guard ![.connected, .connecting].contains(status)
else {
logger.log("\(#function): Already connected")
return
}
tunnel.isEnabled = true
try await tunnel.saveToPreferences()
try await tunnel.loadFromPreferences()
manager.isEnabled = true
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
let session = castToSession(tunnel.connection)
let session = castToSession(manager.connection)
do {
try session.startTunnel()
} catch {
throw TunnelStoreError.startTunnelErrored(error)
}
try await withCheckedThrowingContinuation { continuation in
self.startTunnelContinuation = continuation
}
}
func stop() async throws {
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
logger.log("\(#function)")
guard let manager = manager else {
logger.error("\(#function): No manager created yet")
return
}
guard self.stopTunnelContinuation == nil else {
throw TunnelStoreError.stopAlreadyBeingAttempted
}
logger.log("\(#function)")
let status = tunnel.connection.status
if status == .connected || status == .connecting {
let session = castToSession(tunnel.connection)
session.stopTunnel()
try await withCheckedThrowingContinuation { continuation in
self.stopTunnelContinuation = continuation
}
guard [.connected, .connecting, .reasserting].contains(status)
else {
logger.error("\(#function): Already stopped")
return
}
let session = castToSession(manager.connection)
session.stopTunnel()
}
func signOut() async throws -> Keychain.PersistentRef? {
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
return nil
public func cancelSignIn() {
auth.cancelSignIn()
}
func signIn() async throws {
logger.log("\(#function)")
guard let manager = manager,
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
logger.error("\(#function): Can't sign in if our tunnel configuration is missing!")
return
}
let tunnelStatus = tunnel.connection.status
if tunnelStatus == .connected || tunnelStatus == .connecting {
throw TunnelStoreError.cannotSignOutWhenConnected
let authURL = URL(string: settings.authBaseURL)!
let authResponse = try await auth.signIn(authURL)
// Apple recommends updating Keychain items in place if possible
var tokenRef: Keychain.PersistentRef
if let ref = await keychain.search() {
try await keychain.update(authResponse.token)
tokenRef = ref
} else {
tokenRef = try await keychain.add(authResponse.token)
}
if case .signedIn(_, let tokenReference) = self.tunnelAuthStatus {
do {
try await saveAuthStatus(.signedOut)
} catch {
logger.log(
"\(#function): Error saving signed out auth status: \(error)"
)
}
return tokenReference
// Save token and actorName
providerConfiguration[TunnelStoreKeys.actorName] = authResponse.actorName
protocolConfiguration.providerConfiguration = providerConfiguration
protocolConfiguration.passwordReference = tokenRef
manager.protocolConfiguration = protocolConfiguration
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
// Start tunnel
try await start()
}
func signOut() async throws {
logger.log("\(#function)")
guard let manager = manager,
![.disconnecting, .disconnected].contains(status),
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol
else {
logger.error("\(#function): Tunnel seems to be already disconnected")
return
}
return nil
// Clear token from VPN profile, but keep it in Keychain because the user
// may have customized the Keychain Item.
protocolConfiguration.passwordReference = nil
try await manager.saveToPreferences()
// Stop tunnel
try await stop()
}
func beginUpdatingResources() {
logger.log("\(#function)")
self.updateResources()
let intervalInSeconds: TimeInterval = 1
let timer = Timer(timeInterval: intervalInSeconds, repeats: true) { [weak self] _ in
@@ -234,31 +271,64 @@ public final class TunnelStore: ObservableObject {
self.resourcesTimer = nil
}
func save(_ settings: Settings) async throws {
logger.log("\(#function)")
guard let manager = manager,
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
logger.error("Manager doesn't seem initialized. Can't save advanced settings.")
throw TunnelStoreError.cannotSaveIfMissing
}
if [.connected, .connecting].contains(manager.connection.status) {
throw TunnelStoreError.cannotSaveWhenConnected
}
providerConfiguration = settings.toProviderConfiguration()
protocolConfiguration.providerConfiguration = providerConfiguration
protocolConfiguration.serverAddress = providerConfiguration[TunnelStoreKeys.apiURL]
manager.protocolConfiguration = protocolConfiguration
try await manager.saveToPreferences()
self.settings = settings
self.manager = manager
self.status = manager.connection.status
}
func actorName() -> String? {
guard let manager = manager,
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration
else {
logger.error("\(#function): Tunnel not initialized!")
return nil
}
return providerConfiguration[TunnelStoreKeys.actorName] as? String
}
private func castToSession(_ connection: NEVPNConnection) -> NETunnelProviderSession {
guard let session = connection as? NETunnelProviderSession else {
fatalError("Could not cast tunnel connection to NETunnelProviderSession!")
}
return session
}
private func updateResources() {
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
guard let manager = manager
else {
logger.error("\(#function): No tunnel created yet")
return
}
let session = castToSession(tunnel.connection)
guard session.status == .connected else {
self.resources = DisplayableResources()
return
}
let resourcesQuery = resources.versionStringToData()
let session = castToSession(manager.connection)
let hash = Data(SHA256.hash(data: Data((resourceListJSON ?? "").utf8)))
do {
try session.sendProviderMessage(resourcesQuery) { [weak self] reply in
if let reply = reply { // If reply is nil, then the resources have not changed
if let updatedResources = DisplayableResources(from: reply) {
self?.resources = updatedResources
}
try session.sendProviderMessage(hash) { [weak self] reply in
if let reply = reply {
self?.resourceListJSON = String(data: reply, encoding: .utf8)
}
}
} catch {
@@ -266,6 +336,8 @@ public final class TunnelStore: ObservableObject {
}
}
// Receive notifications about our VPN profile status changing,
// and sync our status to it so UI components can react accordingly.
private func setupTunnelObservers() {
logger.log("\(#function)")
@@ -275,80 +347,87 @@ public final class TunnelStore: ObservableObject {
tunnelObservingTasks.append(
Task {
for await notification in NotificationCenter.default.notifications(
named: .NEVPNStatusDidChange,
object: nil
named: .NEVPNStatusDidChange
) {
guard let session = notification.object as? NETunnelProviderSession else {
return
}
let status = session.status
self.status = status
if let startTunnelContinuation = self.startTunnelContinuation {
switch status {
case .connected:
startTunnelContinuation.resume(returning: ())
self.startTunnelContinuation = nil
case .disconnected:
startTunnelContinuation.resume(throwing: TunnelStoreError.tunnelCouldNotBeStarted)
self.startTunnelContinuation = nil
default:
break
}
}
if let stopTunnelContinuation = self.stopTunnelContinuation {
switch status {
case .disconnected:
stopTunnelContinuation.resume(returning: ())
self.stopTunnelContinuation = nil
case .connected:
stopTunnelContinuation.resume(throwing: TunnelStoreError.tunnelCouldNotBeStopped)
self.stopTunnelContinuation = nil
default:
break
}
}
if status != .connected {
self.resources = DisplayableResources()
await self.handleVPNStatusChange(notification)
}
for await _ in NotificationCenter.default.notifications(
named: .NEVPNConfigurationChange
) {
do {
try await self.handleVPNConfigurationChange()
} catch {
logger.error(
"\(#function): Error while trying to handle VPN configuration change: \(error)")
}
}
}
)
}
func removeProfile() async throws {
logger.log("\(#function)")
guard let tunnel = tunnel else {
logger.log("\(#function): No tunnel created yet")
private func handleVPNStatusChange(_ notification: Notification) async {
guard let session = notification.object as? NETunnelProviderSession
else {
logger.error("\(#function): NEVPNStatusDidChange notification doesn't seem to be valid")
return
}
logger.log("\(#function): NEVPNStatusDidChange: \(session.status)")
status = session.status
if case .disconnected = status,
let savedValue = try? String(contentsOf: SharedAccess.providerStopReasonURL, encoding: .utf8),
let rawValue = Int(savedValue),
let reason = NEProviderStopReason(rawValue: rawValue),
case .authenticationCanceled = reason
{
await consumeCanceledAuthentication()
}
if status != .connected {
// Reset resources list
resourceListJSON = nil
}
}
// Handle cases where our stored tunnel manager changes out from under us.
// This can happen for example if another VPN app turns on and the system
// decides to turn ours off.
private func handleVPNConfigurationChange() async throws {
guard let manager = manager
else {
logger.error("\(#function): Our manager is somehow gone!")
return
}
try await tunnel.removeFromPreferences()
}
}
try await manager.loadFromPreferences()
enum TunnelAuthStatus: Equatable, CustomStringConvertible {
case uninitialized
case noTunnelFound
case signedOut
case signedIn(authBaseURL: URL, tokenReference: Data)
if !manager.isEnabled {
logger.log("\(#function): Something turned us off! Shutting down the tunnel")
var isInitialized: Bool {
switch self {
case .uninitialized: return false
default: return true
try await stop()
}
}
var description: String {
switch self {
case .uninitialized:
return "tunnel uninitialized"
case .noTunnelFound:
return "no tunnel found"
case .signedOut:
return "signedOut"
case .signedIn(let authBaseURL, _):
return "signedIn(authBaseURL: \(authBaseURL))"
private func consumeCanceledAuthentication() async {
try? FileManager.default.removeItem(at: SharedAccess.providerStopReasonURL)
// Show alert (macOS -- iOS is handled in the PacketTunnelProvider)
// TODO: See if we can show standard notifications NotificationCenter here
#if os(macOS)
DispatchQueue.main.async {
SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, tunnelStore: self)
}
#endif
// Clear tokenRef
guard let manager = manager else { return }
let protocolConfiguration = manager.protocolConfiguration
protocolConfiguration?.passwordReference = nil
manager.protocolConfiguration = protocolConfiguration
do {
try await manager.saveToPreferences()
} catch {
logger.error("\(#function): Couldn't clear tokenRef")
}
}
}
@@ -369,145 +448,3 @@ extension NEVPNStatus: CustomStringConvertible {
}
}
}
extension NETunnelProviderManager {
func authStatus() -> TunnelAuthStatus {
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
let providerConfig = protocolConfiguration.providerConfiguration
{
let authBaseURL: URL? = {
guard let urlString = providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String
else {
return nil
}
return URL(string: urlString)
}()
let tokenRef = protocolConfiguration.passwordReference
if let authBaseURL = authBaseURL {
if let tokenRef = tokenRef {
return .signedIn(authBaseURL: authBaseURL, tokenReference: tokenRef)
} else {
return .signedOut
}
} else {
return .signedOut
}
}
return .signedOut
}
func saveAuthStatus(_ authStatus: TunnelAuthStatus) async throws {
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:]
switch authStatus {
case .uninitialized, .noTunnelFound:
return
case .signedOut:
protocolConfiguration.passwordReference = nil
break
case .signedIn(let authBaseURL, let tokenReference):
providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString
protocolConfiguration.passwordReference = tokenReference
}
protocolConfiguration.providerConfiguration = providerConfig
ensureTunnelConfigurationIsValid()
try await saveToPreferences()
}
}
func advancedSettings() -> AdvancedSettings {
let defaultValue = AdvancedSettings.defaultValue
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
let apiURLString = protocolConfiguration.serverAddress ?? defaultValue.apiURLString
var authBaseURLString = defaultValue.authBaseURLString
var logFilter = defaultValue.connlibLogFilterString
if let providerConfig = protocolConfiguration.providerConfiguration {
if let authBaseURLStringInProviderConfig =
(providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String)
{
authBaseURLString = authBaseURLStringInProviderConfig
}
if let logFilterInProviderConfig =
(providerConfig[TunnelProviderKeys.keyConnlibLogFilter] as? String)
{
logFilter = logFilterInProviderConfig
}
}
return AdvancedSettings(
authBaseURLString: authBaseURLString,
apiURLString: apiURLString,
connlibLogFilterString: logFilter
)
}
return defaultValue
}
func setConnlibLogFilter(_ logFiler: String) {
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration
{
var providerConfig = providerConfiguration
providerConfig[TunnelProviderKeys.keyConnlibLogFilter] = logFiler
protocolConfiguration.providerConfiguration = providerConfig
}
}
func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws {
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:]
providerConfig[TunnelProviderKeys.keyAuthBaseURLString] =
advancedSettings.authBaseURLString
providerConfig[TunnelProviderKeys.keyConnlibLogFilter] =
advancedSettings.connlibLogFilterString
protocolConfiguration.providerConfiguration = providerConfig
protocolConfiguration.serverAddress = advancedSettings.apiURLString
ensureTunnelConfigurationIsValid()
try await saveToPreferences()
}
}
private func ensureTunnelConfigurationIsValid() {
// Ensure the tunnel config has required values populated, because
// to even sign out, we need saveToPreferences() to succeed.
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
protocolConfiguration.providerBundleIdentifier =
Self.bundleIdentifier()
if protocolConfiguration.serverAddress?.isEmpty ?? true {
protocolConfiguration.serverAddress = "unknown-server"
}
} else {
let protocolConfiguration = NETunnelProviderProtocol()
protocolConfiguration.providerBundleIdentifier =
Self.bundleIdentifier()
protocolConfiguration.serverAddress = "unknown-server"
}
if localizedDescription?.isEmpty ?? true {
localizedDescription = Self.bundleDescription()
}
}
static func bundleIdentifier() -> String? {
#if DEBUG
Bundle.main.bundleIdentifier.map { "\($0).debug.network-extension" }
#else
Bundle.main.bundleIdentifier.map { "\($0).network-extension" }
#endif
}
static func bundleDescription() -> String {
#if DEBUG
"Firezone (Debug)"
#else
"Firezone"
#endif
}
}

View File

@@ -17,9 +17,10 @@
private var cancellables: Set<AnyCancellable> = []
private var statusItem: NSStatusItem
private var orderedResources: [DisplayableResources.Resource] = []
private var resources: [Resource]?
private var isMenuVisible = false {
didSet { handleMenuVisibilityOrStatusChanged() }
didSet { handleMenuVisibilityOrStatusChanged(status: tunnelStore.status) }
}
private lazy var signedOutIcon = NSImage(named: "MenuBarIconSignedOut")
private lazy var signedInConnectedIcon = NSImage(named: "MenuBarIconSignedInConnected")
@@ -32,60 +33,62 @@
private var connectingAnimationImageIndex: Int = 0
private var connectingAnimationTimer: Timer?
private var appStore: AppStore
private var tunnelStore: TunnelStore
private var settingsViewModel: SettingsViewModel
private var loginStatus: AuthStore.LoginStatus = .signedOut
private var tunnelStatus: NEVPNStatus = .invalid
private var logger: AppLogger
public init(appStore: AppStore) {
public init(tunnelStore: TunnelStore, settingsViewModel: SettingsViewModel, logger: AppLogger) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.appStore = appStore
self.settingsViewModel = appStore.settingsViewModel
self.logger = appStore.logger
self.tunnelStore = tunnelStore
self.settingsViewModel = settingsViewModel
self.logger = logger
super.init()
createMenu()
setupObservers()
if let button = statusItem.button {
button.image = signedOutIcon
}
updateStatusItemIcon()
createMenu()
setupObservers()
}
private func setupObservers() {
appStore.authStore.$loginStatus
.receive(on: mainQueue)
.sink { [weak self] loginStatus in
self?.loginStatus = loginStatus
self?.updateStatusItemIcon()
self?.handleLoginOrTunnelStatusChanged()
}
.store(in: &cancellables)
appStore.tunnelStore.$status
tunnelStore.$status
.receive(on: mainQueue)
.sink { [weak self] status in
self?.tunnelStatus = status
self?.updateStatusItemIcon()
self?.handleLoginOrTunnelStatusChanged()
self?.handleMenuVisibilityOrStatusChanged()
guard let self = self else { return }
self.updateStatusItemIcon(status: status)
self.handleTunnelStatusChanged(status: status)
self.handleMenuVisibilityOrStatusChanged(status: status)
}
.store(in: &cancellables)
appStore.tunnelStore.$resources
tunnelStore.$resourceListJSON
.receive(on: mainQueue)
.sink { [weak self] resources in
.sink { [weak self] json in
guard let self = self else { return }
self.setOrderedResources(resources.orderedResources)
let newResources = decodeResources(json)
// Update menu in-place for smooth, silky, easy-on-the-eyes UI updates
populateResourceMenu(newResources)
resourcesTitleMenuItem.title = resourceMenuTitle(newResources)
// Save what we got so we know when it changes next
resources = newResources
}
.store(in: &cancellables)
}
private func decodeResources(_ json: String?) -> [Resource]? {
guard let json = json,
let data = json.data(using: .utf8)
else { return nil }
return try? JSONDecoder().decode([Resource].self, from: data)
}
// FIXME: Use SwiftUI for the menubar
private lazy var menu = NSMenu()
private lazy var signInMenuItem = createMenuItem(
@@ -103,7 +106,7 @@
)
private lazy var resourcesTitleMenuItem = createMenuItem(
menu,
title: "No Resources",
title: "Loading Resources...",
action: nil,
isHidden: true,
target: self
@@ -191,16 +194,10 @@
return item
}
@objc private func reconnectButtonTapped() {
if case .signedIn = appStore.authStore.loginStatus {
appStore.authStore.startTunnel()
}
}
@objc private func signInButtonTapped() {
Task {
do {
try await appStore.authStore.signIn()
try await tunnelStore.signIn()
} catch {
logger.error("Error signing in: \(String(describing: error))")
}
@@ -209,7 +206,7 @@
@objc private func signOutButtonTapped() {
Task {
await appStore.authStore.signOut()
try await tunnelStore.signOut()
}
}
@@ -225,7 +222,7 @@
@objc private func quitButtonTapped() {
Task {
do {
try await appStore.tunnelStore.stop()
try await tunnelStore.stop()
} catch {
logger.error("\(#function): Error stopping tunnel: \(error)")
}
@@ -233,32 +230,27 @@
}
}
private func updateStatusItemIcon() {
private func updateStatusItemIcon(status: NEVPNStatus) {
self.statusItem.button?.image = {
switch self.loginStatus {
case .signedOut, .uninitialized, .needsTunnelCreationPermission:
switch status {
case .invalid, .disconnected:
self.stopConnectingAnimation()
return self.signedOutIcon
case .signedIn:
switch self.tunnelStatus {
case .connected:
self.stopConnectingAnimation()
return self.signedInConnectedIcon
case .connecting, .disconnecting, .reasserting:
self.startConnectingAnimation()
return self.connectingAnimationImages.last!
case .invalid, .disconnected:
self.stopConnectingAnimation()
return self.signedOutIcon
@unknown default:
return nil
}
case .connected:
self.stopConnectingAnimation()
return self.signedInConnectedIcon
case .connecting, .disconnecting, .reasserting:
self.startConnectingAnimation()
return self.connectingAnimationImages.last!
@unknown default:
return nil
}
}()
}
private func startConnectingAnimation() {
guard connectingAnimationTimer == nil else { return }
let timer = Timer(timeInterval: 0.40, repeats: true) { [weak self] _ in
let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] _ in
guard let self = self else { return }
Task {
await self.connectingAnimationShowNextFrame()
@@ -269,100 +261,94 @@
}
private func stopConnectingAnimation() {
guard let timer = self.connectingAnimationTimer else { return }
timer.invalidate()
connectingAnimationTimer = nil
connectingAnimationImageIndex = 0
connectingAnimationTimer?.invalidate()
self.connectingAnimationTimer = nil
}
private func connectingAnimationShowNextFrame() async {
private func connectingAnimationShowNextFrame() {
self.statusItem.button?.image =
self.connectingAnimationImages[self.connectingAnimationImageIndex]
self.connectingAnimationImageIndex =
(self.connectingAnimationImageIndex + 1) % self.connectingAnimationImages.count
}
private func handleLoginOrTunnelStatusChanged() {
private func handleTunnelStatusChanged(status: NEVPNStatus) {
// Update "Sign In" / "Sign Out" menu items
switch self.loginStatus {
case .uninitialized:
signInMenuItem.title = "Initializing"
signInMenuItem.target = nil
signOutMenuItem.isHidden = true
settingsMenuItem.target = nil
case .needsTunnelCreationPermission:
switch status {
case .invalid:
signInMenuItem.title = "Requires VPN permission"
signInMenuItem.target = nil
signOutMenuItem.isHidden = true
settingsMenuItem.target = nil
case .signedOut:
case .disconnected:
signInMenuItem.title = "Sign In"
signInMenuItem.target = self
signInMenuItem.isEnabled = true
signOutMenuItem.isHidden = true
settingsMenuItem.target = self
case .signedIn(let actorName):
signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)"
case .disconnecting:
signInMenuItem.title = "Signing out..."
signInMenuItem.target = self
signInMenuItem.isEnabled = false
signOutMenuItem.isHidden = true
settingsMenuItem.target = self
case .connected, .reasserting, .connecting:
let title =
if let actorName = tunnelStore.actorName() {
"Signed in as \(actorName)"
} else {
"Signed in"
}
signInMenuItem.title = title
signInMenuItem.target = nil
signOutMenuItem.isHidden = false
settingsMenuItem.target = self
@unknown default:
break
}
// Update resources "header" menu items
switch (self.loginStatus, self.tunnelStatus) {
case (.uninitialized, _):
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesSeparatorMenuItem.isHidden = true
case (.needsTunnelCreationPermission, _):
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesSeparatorMenuItem.isHidden = true
case (.signedOut, _):
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesSeparatorMenuItem.isHidden = true
case (.signedIn, .connecting):
switch tunnelStore.status {
case .connecting:
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.target = nil
resourcesUnavailableReasonMenuItem.title = "Connecting…"
resourcesSeparatorMenuItem.isHidden = false
case (.signedIn, .connected):
case .connected:
resourcesTitleMenuItem.isHidden = false
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesTitleMenuItem.title = "Resources"
resourcesTitleMenuItem.title = resourceMenuTitle(resources)
resourcesSeparatorMenuItem.isHidden = false
case (.signedIn, .reasserting):
case .reasserting:
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.target = nil
resourcesUnavailableReasonMenuItem.title = "No network connectivity"
resourcesSeparatorMenuItem.isHidden = false
case (.signedIn, .disconnecting):
case .disconnecting:
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.target = nil
resourcesUnavailableReasonMenuItem.title = "Disconnecting…"
resourcesSeparatorMenuItem.isHidden = false
case (.signedIn, .disconnected), (.signedIn, .invalid), (.signedIn, _):
case .disconnected, .invalid:
// We should never be in a state where the tunnel is
// down but the user is signed in, but we have
// code to handle it just for the sake of completion.
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = false
resourcesUnavailableReasonMenuItem.isHidden = false
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.title = "Disconnected"
resourcesSeparatorMenuItem.isHidden = false
resourcesSeparatorMenuItem.isHidden = true
@unknown default:
break
}
quitMenuItem.title = {
switch self.tunnelStatus {
switch status {
case .connected, .connecting:
return "Disconnect and Quit"
default:
@@ -371,38 +357,40 @@
}()
}
private func handleMenuVisibilityOrStatusChanged() {
let status = appStore.tunnelStore.status
if isMenuVisible && status == .connected {
appStore.tunnelStore.beginUpdatingResources()
private func resourceMenuTitle(_ newResources: [Resource]?) -> String {
guard let newResources = newResources else { return "Loading Resources..." }
if newResources.isEmpty {
return "No Resources"
} else {
appStore.tunnelStore.endUpdatingResources()
return "Resources"
}
}
private func setOrderedResources(_ newOrderedResources: [DisplayableResources.Resource]) {
if resourcesTitleMenuItem.isHidden && resourcesSeparatorMenuItem.isHidden {
guard newOrderedResources.isEmpty else {
return
}
private func handleMenuVisibilityOrStatusChanged(status: NEVPNStatus) {
if isMenuVisible && status == .connected {
tunnelStore.beginUpdatingResources()
} else {
tunnelStore.endUpdatingResources()
}
let diff = newOrderedResources.difference(
from: self.orderedResources,
by: { $0.name == $1.name && $0.location == $1.location }
}
private func populateResourceMenu(_ newResources: [Resource]?) {
// the menu contains other things besides resources, so update it in-place
let diff = (newResources ?? []).difference(
from: resources ?? [],
by: { $0.name == $1.name && $0.address == $1.address }
)
let baseIndex = menu.index(of: resourcesTitleMenuItem) + 1
let index = menu.index(of: resourcesTitleMenuItem) + 1
for change in diff {
switch change {
case .insert(let offset, let element, associatedWith: _):
let menuItem = createResourceMenuItem(title: element.name, submenuTitle: element.location)
menu.insertItem(menuItem, at: baseIndex + offset)
orderedResources.insert(element, at: offset)
let menuItem = createResourceMenuItem(title: element.name, submenuTitle: element.address)
menu.insertItem(menuItem, at: index + offset)
case .remove(let offset, element: _, associatedWith: _):
menu.removeItem(at: baseIndex + offset)
orderedResources.remove(at: offset)
menu.removeItem(at: index + offset)
}
}
resourcesTitleMenuItem.title = orderedResources.isEmpty ? "No Resources" : "Resources"
}
private func createResourceMenuItem(title: String, submenuTitle: String) -> NSMenuItem {

View File

@@ -1,3 +1,4 @@
import CryptoKit
// Adapter.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
@@ -7,48 +8,23 @@ import Foundation
import NetworkExtension
import OSLog
#if os(iOS)
import UIKit.UIDevice
#endif
public enum AdapterError: Error {
/// Failure to perform an operation in such state.
case invalidState
/// connlib failed to start
case connlibConnectError(Error)
/// connlib fatal error
case connlibFatalError(String)
/// No network settings were provided
case noNetworkSettings
/// Failure to set network settings.
case setNetworkSettings(Error)
/// stop() called before the tunnel became ready
case stoppedByRequestWhileStarting
}
/// Enum representing internal state of the adapter
private enum AdapterState: CustomStringConvertible {
case startingTunnel(session: WrappedSession, onStarted: Adapter.StartTunnelCompletionHandler?)
case tunnelReady(session: WrappedSession)
case stoppingTunnel(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?)
case stoppedTunnel
case stoppingTunnelTemporarily(
session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?)
case stoppedTunnelTemporarily
case tunnelStarted(session: WrappedSession)
case tunnelStopped
var description: String {
switch self {
case .startingTunnel: return "startingTunnel"
case .tunnelReady: return "tunnelReady"
case .stoppingTunnel: return "stoppingTunnel"
case .stoppedTunnel: return "stoppedTunnel"
case .stoppingTunnelTemporarily: return "stoppingTunnelTemporarily"
case .stoppedTunnelTemporarily: return "stoppedTunnelTemporarily"
case .tunnelStarted: return "tunnelStarted"
case .tunnelStopped: return "tunnelStopped"
}
}
}
@@ -57,7 +33,6 @@ private enum AdapterState: CustomStringConvertible {
class Adapter {
typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void)
typealias StopTunnelCompletionHandler = (() -> Void)
private let logger: AppLogger
@@ -72,7 +47,19 @@ class Adapter {
/// Network routes monitor.
private var networkMonitor: NWPathMonitor?
/// Private queue used to synchronize access to `WireGuardAdapter` members.
/// Used to avoid path update callback cycles on iOS
#if os(iOS)
private var gateways: [Network.NWEndpoint] = []
#endif
/// Track our last fetched DNS resolvers to know whether to tell connlib they've updated
private var lastFetchedResolvers: [String] = []
/// Used to avoid needlessly sending reconnects to connlib
private var primaryInterfaceName: String?
/// Private queue used to ensure consistent ordering among path update and connlib callbacks
/// This is the primary async primitive used in this class.
private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue")
/// Adapter state.
@@ -83,227 +70,145 @@ class Adapter {
}
/// Keep track of resources
private var displayableResources = DisplayableResources()
private var resourceListJSON: String?
/// Starting parameters
private var controlPlaneURLString: String
private var apiURL: String
private var token: String
private let logFilter: String
private let connlibLogFolderPath: String
private let firezoneIdFileURL: URL
init(
controlPlaneURLString: String, token: String,
logFilter: String, packetTunnelProvider: PacketTunnelProvider
apiURL: String,
token: String,
logFilter: String,
packetTunnelProvider: PacketTunnelProvider
) {
self.controlPlaneURLString = controlPlaneURLString
self.apiURL = apiURL
self.token = token
self.packetTunnelProvider = packetTunnelProvider
self.callbackHandler = CallbackHandler(logger: packetTunnelProvider.logger)
self.state = .stoppedTunnel
self.state = .tunnelStopped
self.logFilter = logFilter
self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? ""
self.firezoneIdFileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id")
self.logger = packetTunnelProvider.logger
self.networkSettings = nil
}
// Could happen abruptly if the process is killed.
deinit {
self.logger.log("Adapter.deinit")
logger.log("Adapter.deinit")
// Cancel network monitor
networkMonitor?.cancel()
// Shutdown the tunnel
if case .tunnelReady(let session) = self.state {
if case .tunnelStarted(let session) = self.state {
logger.log("Adapter.deinit: Shutting down connlib")
session.disconnect()
}
}
/// Start the tunnel tunnel.
/// Start the tunnel.
/// - Parameters:
/// - completionHandler: completion handler.
public func start(completionHandler: @escaping (AdapterError?) -> Void) throws {
workQueue.async { [weak self] in
guard let self = self else { return }
logger.log("Adapter.start")
guard case .tunnelStopped = self.state else {
logger.error("\(#function): Invalid Adapter state")
completionHandler(.invalidState)
return
}
self.logger.log("Adapter.start")
guard case .stoppedTunnel = self.state else {
logger.error("\(#function): Invalid Adapter state")
completionHandler(.invalidState)
return
}
callbackHandler.delegate = self
self.callbackHandler.delegate = self
if connlibLogFolderPath.isEmpty {
logger.error("Cannot get shared log folder for connlib")
}
if self.connlibLogFolderPath.isEmpty {
self.logger.error("Cannot get shared log folder for connlib")
}
self.logger.log("Adapter.start: Starting connlib")
do {
// We can only get the system's default resolvers before connlib starts, and then they'll
// be overwritten by the ones from connlib. So cache them here for getSystemDefaultResolvers
// to retrieve them later.
self.callbackHandler.setSystemDefaultResolvers(
resolvers: Resolv().getservers().map(Resolv.getnameinfo)
self.logger.log("Adapter.start: Starting connlib")
do {
// Grab a session pointer
let session =
try WrappedSession.connect(
apiURL,
token,
DeviceMetadata.getOrCreateFirezoneId(logger: self.logger),
DeviceMetadata.getDeviceName(),
DeviceMetadata.getOSVersion(),
connlibLogFolderPath,
logFilter,
callbackHandler
)
self.state = .startingTunnel(
session: try WrappedSession.connect(
self.controlPlaneURLString,
self.token,
self.getOrCreateFirezoneId(from: self.firezoneIdFileURL),
self.getDeviceName(),
self.getOSVersion(),
self.connlibLogFolderPath,
self.logFilter,
self.callbackHandler
),
onStarted: completionHandler
)
} catch let error {
self.logger.error("Adapter.start: Error: \(error)")
packetTunnelProvider?.handleTunnelShutdown(
dueTo: .connlibConnectFailure,
errorMessage: error.localizedDescription)
self.state = .stoppedTunnel
completionHandler(AdapterError.connlibConnectError(error))
// Attempt to set DNS right away
if let jsonResolvers = try? String(
decoding: JSONEncoder().encode(getSystemDefaultResolvers()), as: UTF8.self
).intoRustString() {
session.setDns(jsonResolvers)
}
// Update our internal state
self.state = .tunnelStarted(session: session)
// Start listening for network change events. The first few will be our
// tunnel interface coming up, but that's ok -- it will trigger a `set_dns`
// connlib.
beginPathMonitoring()
// Tell the system the tunnel is up, moving the tunnelManager status to
// `connected`.
completionHandler(nil)
} catch let error {
logger.error("\(#function): Adapter.start: Error: \(error)")
state = .tunnelStopped
completionHandler(AdapterError.connlibConnectError(error))
}
}
/// Stop the tunnel
public func stop(reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
workQueue.async { [weak self] in
guard let self = self else { return }
/// Final callback called by packetTunnelProvider when tunnel is to be stopped.
/// Can happen due to:
/// - User toggles VPN off in Settings.app
/// - User signs out
/// - User clicks "Disconnect and Quit" (macOS)
/// - connlib sends onDisconnect
///
/// This can happen before the tunnel is in the tunnelReady state, such as if the portal
/// is slow to send the init.
public func stop() {
logger.log("Adapter.stop")
self.logger.log("Adapter.stop")
if case .tunnelStarted(let session) = state {
state = .tunnelStopped
packetTunnelProvider?.handleTunnelShutdown(
dueTo: .stopped(reason),
errorMessage: "\(reason)")
switch self.state {
case .stoppedTunnel, .stoppingTunnel:
break
case .tunnelReady(let session):
self.logger.log("Adapter.stop: Shutting down connlib")
session.disconnect()
self.state = .stoppedTunnel
completionHandler()
case .startingTunnel(let session, let onStarted):
self.logger.log("Adapter.stop: Shutting down connlib before tunnel ready")
session.disconnect()
self.state = .stoppedTunnel
onStarted?(AdapterError.stoppedByRequestWhileStarting)
completionHandler()
case .stoppingTunnelTemporarily(let session, let onStopped):
self.state = .stoppedTunnel
onStopped?()
completionHandler()
case .stoppedTunnelTemporarily:
self.state = .stoppedTunnel
completionHandler()
}
self.networkMonitor?.cancel()
self.networkMonitor = nil
// user-initiated, tell connlib to shut down
session.disconnect()
}
networkMonitor?.cancel()
networkMonitor = nil
}
/// Get the current set of resources in the completionHandler.
/// If unchanged since referenceVersionString, call completionHandler(nil).
public func getDisplayableResourcesIfVersionDifferentFrom(
referenceVersionString: String, completionHandler: @escaping (DisplayableResources?) -> Void
/// Get the current set of resources in the completionHandler, only returning
/// them if the resource list has changed.
public func getResourcesIfVersionDifferentFrom(
hash: Data, completionHandler: @escaping (String?) -> Void
) {
// This is async to avoid blocking the main UI thread
workQueue.async { [weak self] in
guard let self = self else { return }
if referenceVersionString == self.displayableResources.versionString {
if hash == Data(SHA256.hash(data: Data((resourceListJSON ?? "").utf8))) {
// nothing changed
completionHandler(nil)
} else {
completionHandler(self.displayableResources)
completionHandler(resourceListJSON)
}
}
}
}
// MARK: Device metadata
extension Adapter {
func getDeviceName() -> String? {
// Returns a generic device name on iOS 16 and higher
// See https://github.com/firezone/firezone/issues/3034
#if os(iOS)
return UIDevice.current.name
#else
// Fallback to connlib's gethostname()
return nil
#endif
}
func getOSVersion() -> String? {
// Returns the OS version
// See https://github.com/firezone/firezone/issues/3034
#if os(iOS)
return UIDevice.current.systemVersion
#else
// Fallback to connlib's osinfo
return nil
#endif
}
// Returns the Firezone ID as cached by the application or generates and persists a new one
// if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device
// for upsert and identification in the admin portal.
func getOrCreateFirezoneId(from fileURL: URL) -> String {
do {
let storedID = try String(contentsOf: fileURL, encoding: .utf8)
self.logger.log("Adapter.getOrCreateFirezoneId: Returning ID from disk: \(storedID)")
return storedID
} catch {
self.logger.log(
"Adapter.getOrCreateFirezoneId: Could not read firezone-id file \(fileURL.path): \(error)"
)
// Handle the error if the file doesn't exist or isn't readable
// Recreate the file, save a new UUIDv4, and return it
let newUUIDString = UUID().uuidString
do {
try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8)
self.logger.log("Adapter.getOrCreateFirezoneId: Written ID to disk: \(newUUIDString)")
} catch {
self.logger.error(
"Adapter.getOrCreateFirezoneId: Could not save firezone-id file \(fileURL.path)! Error: \(error)"
)
}
return newUUIDString
}
}
}
// MARK: Responding to path updates
extension Adapter {
private func resetToSystemDNS() {
// Setting this to anything but an empty string will populate /etc/resolv.conf with
// the default interface's DNS servers, which we read later from connlib
// during tunnel setup.
self.networkSettings?.setMatchDomains(["firezone-fd0020211111"])
self.networkSettings?.apply(
on: self.packetTunnelProvider,
logger: self.logger,
completionHandler: { _ in
// We can only get the system's default resolvers before connlib starts, and then they'll
// be overwritten by the ones from connlib. So cache them here for getSystemDefaultResolvers
// to retrieve them later.
self.callbackHandler.setSystemDefaultResolvers(
resolvers: Resolv().getservers().map(Resolv.getnameinfo)
)
})
}
private func beginPathMonitoring() {
self.logger.log("Beginning path monitoring")
let networkMonitor = NWPathMonitor()
@@ -313,71 +218,110 @@ extension Adapter {
networkMonitor.start(queue: self.workQueue)
}
/// Primary callback we receive whenever:
/// - Network connectivity changes
/// - System DNS servers change, including when we set them
/// - Routes change, including when we set them
///
/// Apple doesn't give us very much info in this callback, so we don't know which of the
/// events above triggered the callback.
///
/// On iOS this creates a problem:
///
/// We have no good way to get the System's default resolvers. We use a workaround which
/// involves reading the resolvers from Bind (i.e. /etc/resolv.conf) but this will be set to connlib's
/// DNS sentinel while the tunnel is active, which isn't helpful to us. To get around this, we can
/// very briefly update the Tunnel's matchDomains config to *not* be the catch-all [""], which
/// causes iOS to write the actual system resolvers into /etc/resolv.conf, which we can then read.
/// The issue is that this in itself causes a didReceivePathUpdate callback, which makes it hard to
/// differentiate between us changing the DNS configuration and the system actually receiving new
/// default resolvers.
///
/// So we solve this problem by only doing this DNS dance if the gateways available to the path have
/// changed. This means we only call setDns when the physical network has changed, and therefore
/// we're blind to path updates where only the DNS resolvers have changed. That will happen in two
/// cases most commonly:
/// - New DNS servers were set by DHCP
/// - The user manually changed the DNS servers in the system settings
///
/// For now, this will break DNS if the old servers connlib is using are no longer valid, and
/// can only be fixed with a sign out and sign back in which restarts the NetworkExtension.
///
/// On macOS, Apple has exposed the SystemConfiguration framework which makes this easy and
/// doesn't suffer from this issue.
///
/// See the following issues for discussion around the above issue:
/// - https://github.com/firezone/firezone/issues/3302
/// - https://github.com/firezone/firezone/issues/3343
/// - https://github.com/firezone/firezone/issues/3235
/// - https://github.com/firezone/firezone/issues/3175
private func didReceivePathUpdate(path: Network.NWPath) {
// Will be invoked in the workQueue by the path monitor
switch self.state {
// Ignore path updates if we're not started. Prevents responding to path updates
// we may receive when shutting down.
guard case .tunnelStarted(let session) = state else { return }
case .startingTunnel(let session, let onStarted):
if path.status != .satisfied {
self.logger.log("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.")
onStarted?(nil)
resetToSystemDNS()
self.packetTunnelProvider?.reasserting = true
self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil)
session.disconnect()
if path.status == .unsatisfied {
logger.log("\(#function): Detected network change: Offline.")
// Check if we need to set reasserting, avoids OS log spam and potentially other side effects
if packetTunnelProvider?.reasserting == false {
// Tell the UI we're not connected
packetTunnelProvider?.reasserting = true
}
} else {
self.logger.log("\(#function): Detected network change: Online.")
// Hint to connlib we're back online, but only do so if our primary interface changes,
// and therefore we need to bump sockets. On darwin, this is needed to send packets
// out of a different interface even when 0.0.0.0 is used as the source.
// If our primary interface changes, we can be certain the old socket shouldn't be
// used anymore.
if path.availableInterfaces.first?.name != primaryInterfaceName {
session.reconnect()
primaryInterfaceName = path.availableInterfaces.first?.name
}
case .tunnelReady(let session):
if path.status != .satisfied {
self.logger.log("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.")
resetToSystemDNS()
self.packetTunnelProvider?.reasserting = true
self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil)
session.disconnect()
}
if shouldFetchSystemResolvers(path: path) {
// Spawn a new thread to avoid blocking the UI on iOS
Task {
let resolvers = getSystemDefaultResolvers(
interfaceName: path.availableInterfaces.first?.name)
case .stoppingTunnelTemporarily:
break
if lastFetchedResolvers != resolvers,
let jsonResolvers = try? String(
decoding: JSONEncoder().encode(resolvers), as: UTF8.self
).intoRustString()
{
case .stoppedTunnelTemporarily:
guard path.status == .satisfied else { return }
// Update connlib DNS
session.setDns(jsonResolvers)
self.logger.log("Adapter.didReceivePathUpdate: Back online. Starting connlib.")
do {
self.state = .startingTunnel(
session: try WrappedSession.connect(
controlPlaneURLString,
token,
self.getOrCreateFirezoneId(from: self.firezoneIdFileURL),
self.getDeviceName(),
self.getOSVersion(),
self.connlibLogFolderPath,
self.logFilter,
self.callbackHandler
),
onStarted: { error in
if let error = error {
self.logger.error(
"Adapter.didReceivePathUpdate: Error starting connlib: \(error)")
self.packetTunnelProvider?.cancelTunnelWithError(error)
} else {
self.packetTunnelProvider?.reasserting = false
}
// Update our state tracker
lastFetchedResolvers = resolvers
}
)
} catch let error as AdapterError {
self.logger.error("Adapter.didReceivePathUpdate: Error: \(error)")
} catch {
self.logger.error(
"Adapter.didReceivePathUpdate: Unknown error: \(error) (fatal)")
}
}
case .stoppingTunnel, .stoppedTunnel:
// no-op
break
if packetTunnelProvider?.reasserting == true {
packetTunnelProvider?.reasserting = false
}
}
}
#if os(iOS)
private func shouldFetchSystemResolvers(path: Network.NWPath) -> Bool {
if path.gateways != gateways {
gateways = path.gateways
return true
}
return false
}
#else
private func shouldFetchSystemResolvers(path _: Network.NWPath) -> Bool {
return true
}
#endif
}
// MARK: Implementing CallbackHandlerDelegate
@@ -386,142 +330,143 @@ extension Adapter: CallbackHandlerDelegate {
public func onSetInterfaceConfig(
tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddresses: [String]
) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
self.logger.log("Adapter.onSetInterfaceConfig")
switch self.state {
case .startingTunnel:
self.networkSettings = NetworkSettings(
tunnelAddressIPv4: tunnelAddressIPv4, tunnelAddressIPv6: tunnelAddressIPv6,
dnsAddresses: dnsAddresses)
case .tunnelReady:
if let networkSettings = self.networkSettings {
networkSettings.apply(
on: packetTunnelProvider,
logger: self.logger,
completionHandler: nil
)
}
case .stoppingTunnel, .stoppedTunnel, .stoppingTunnelTemporarily, .stoppedTunnelTemporarily:
// This is not expected to happen
break
}
}
}
public func onTunnelReady() {
workQueue.async { [weak self] in
guard let self = self else { return }
self.logger.log("Adapter.onTunnelReady")
guard case .startingTunnel(let session, let onStarted) = self.state else {
self.logger.error(
"Adapter.onTunnelReady: Unexpected state: \(self.state)")
return
}
guard let networkSettings = self.networkSettings else {
self.logger.error("Adapter.onTunnelReady: No network settings")
return
if networkSettings == nil {
// First time receiving this callback, so initialize our network settings
networkSettings = NetworkSettings(
packetTunnelProvider: packetTunnelProvider, logger: logger)
}
// Connlib's up, set it as the default DNS
networkSettings.setMatchDomains([""])
networkSettings.apply(on: packetTunnelProvider, logger: self.logger) { error in
if let error = error {
self.logger.error("\(#function): \(error)")
onStarted?(AdapterError.setNetworkSettings(error))
self.state = .stoppedTunnel
} else {
onStarted?(nil)
self.state = .tunnelReady(session: session)
self.beginPathMonitoring()
}
logger.log(
"\(#function): \(tunnelAddressIPv4) \(tunnelAddressIPv6) \(dnsAddresses)")
switch state {
case .tunnelStarted(session: _):
guard let networkSettings = networkSettings else { return }
networkSettings.tunnelAddressIPv4 = tunnelAddressIPv4
networkSettings.tunnelAddressIPv6 = tunnelAddressIPv6
networkSettings.dnsAddresses = dnsAddresses
networkSettings.apply()
case .tunnelStopped:
logger.error(
"\(#function): Unexpected state: \(self.state)")
}
}
}
public func onUpdateRoutes(routeList4: String, routeList6: String) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
self.logger.log("Adapter.onUpdateRoutes \(routeList4) \(routeList6)")
guard let networkSettings = self.networkSettings else {
self.logger.error("Adapter.onUpdateRoutes: No network settings")
return
guard let networkSettings = networkSettings
else {
fatalError("onUpdateRoutes called before network settings was initialized!")
}
logger.log("\(#function): \(routeList4) \(routeList6)")
networkSettings.routes4 = try! JSONDecoder().decode(
[NetworkSettings.Cidr].self, from: routeList4.data(using: .utf8)!
).compactMap { $0.asNEIPv4Route }
networkSettings.routes6 = try! JSONDecoder().decode(
[NetworkSettings.Cidr].self, from: routeList6.data(using: .utf8)!
).compactMap { $0.asNEIPv6Route }
networkSettings.hasUnappliedChanges = true
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
networkSettings.apply()
}
}
public func onUpdateResources(resourceList: String) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
self.logger.log("Adapter.onUpdateResources")
let jsonString = resourceList
guard let jsonData = jsonString.data(using: .utf8) else {
return
}
guard let networkResources = try? JSONDecoder().decode([NetworkResource].self, from: jsonData)
else {
return
}
logger.log("\(#function)")
// Note down the resources
self.displayableResources.update(resources: networkResources.map { $0.displayableResource })
// Update DNS in case resource domains is changing
guard let networkSettings = self.networkSettings else {
self.logger.error("Adapter.onUpdateResources: No network settings")
return
}
let updatedResourceDomains = networkResources.compactMap { $0.resourceLocation.domain }
networkSettings.setResourceDomains(updatedResourceDomains)
if case .tunnelReady = self.state {
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
}
// Update resource List. We don't care what's inside.
resourceListJSON = resourceList
}
}
public func onDisconnect(error: String) {
// Since connlib has already shutdown by this point, we queue this callback
// to ensure that we can clean up even if connlib exits before we are done.
workQueue.async { [weak self] in
guard let self = self else { return }
logger.log("\(#function)")
self.logger.error(
"Connlib disconnected with unrecoverable error: \(error)")
switch self.state {
case .stoppingTunnel(session: _, let onStopped):
onStopped?()
self.state = .stoppedTunnel
case .stoppingTunnelTemporarily(session: _, let onStopped):
onStopped?()
self.state = .stoppedTunnel
case .stoppedTunnel:
// This should not happen
break
case .stoppedTunnelTemporarily:
self.state = .stoppedTunnel
default:
packetTunnelProvider?.handleTunnelShutdown(
dueTo: .connlibDisconnected,
errorMessage: error)
self.packetTunnelProvider?.cancelTunnelWithError(
AdapterError.connlibFatalError(error))
self.state = .stoppedTunnel
// Set a default stop reason. In the future, we may have more to act upon in
// different ways.
var reason: NEProviderStopReason = .connectionFailed
// connlib-initiated -- session is already disconnected, move directly to .tunnelStopped
// provider will call our stop() at the end.
state = .tunnelStopped
// HACK: Define more connlib error types across the FFI so we can switch on them
// directly and not parse error strings here.
if error.contains("401 Unauthorized") {
reason = .authenticationCanceled
}
// Start the process of telling the system to shut us down
self.packetTunnelProvider?.stopTunnel(with: reason) {}
}
}
private func getSystemDefaultResolvers(interfaceName: String? = nil) -> [String] {
#if os(macOS)
let resolvers = SystemConfigurationResolvers(logger: logger).getDefaultDNSServers(
interfaceName: interfaceName)
#elseif os(iOS)
let resolvers = resetToSystemDNSGettingBindResolvers()
#endif
return resolvers
}
}
// MARK: Getting System Resolvers on iOS
#if os(iOS)
extension Adapter {
// When the tunnel is up, we can only get the system's default resolvers
// by reading /etc/resolv.conf when matchDomains is set to a non-empty string.
// If matchDomains is an empty string, /etc/resolv.conf will contain connlib's
// sentinel, which isn't helpful to us.
private func resetToSystemDNSGettingBindResolvers() -> [String] {
guard let networkSettings = networkSettings
else {
// Network Settings hasn't been applied yet, so our sentinel isn't
// the system's resolver and we can grab the system resolvers directly.
// If we try to continue below without valid tunnel addresses assigned
// to the interface, we'll crash.
return BindResolvers().getservers().map(BindResolvers.getnameinfo)
}
var resolvers: [String] = []
// The caller is in an async context, so it's ok to block this thread here.
let semaphore = DispatchSemaphore(value: 0)
// Set tunnel's matchDomains to a dummy string that will never match any name
networkSettings.matchDomains = ["firezone-fd0020211111"]
// Call apply to populate /etc/resolv.conf with the system's default resolvers
networkSettings.apply {
guard let networkSettings = self.networkSettings else { return }
// Only now can we get the system resolvers
resolvers = BindResolvers().getservers().map(BindResolvers.getnameinfo)
// Restore connlib's DNS resolvers
networkSettings.matchDomains = [""]
networkSettings.apply { semaphore.signal() }
}
semaphore.wait()
return resolvers
}
}
#endif

View File

@@ -6,7 +6,7 @@
//
// Reads system resolvers from libresolv, similar to reading /etc/resolv.conf but this also works on iOS
public class Resolv {
public class BindResolvers {
var state = __res_9_state()
public init() {
@@ -27,7 +27,7 @@ public class Resolv {
}
}
extension Resolv {
extension BindResolvers {
public static func getnameinfo(_ s: res_9_sockaddr_union) -> String {
var s = s
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))

View File

@@ -22,7 +22,6 @@ public protocol CallbackHandlerDelegate: AnyObject {
tunnelAddressIPv6: String,
dnsAddresses: [String]
)
func onTunnelReady()
func onUpdateRoutes(routeList4: String, routeList6: String)
func onUpdateResources(resourceList: String)
func onDisconnect(error: String)
@@ -30,12 +29,12 @@ public protocol CallbackHandlerDelegate: AnyObject {
public class CallbackHandler {
public weak var delegate: CallbackHandlerDelegate?
private var systemDefaultResolvers: [String] = []
private let logger: AppLogger
init(logger: AppLogger) {
self.logger = logger
}
func onSetInterfaceConfig(
tunnelAddressIPv4: RustString,
tunnelAddressIPv6: RustString,
@@ -49,13 +48,8 @@ public class CallbackHandler {
DNS: \(dnsAddresses.toString())
""")
guard let dnsData = dnsAddresses.toString().data(using: .utf8) else {
return
}
guard let dnsArray = try? JSONDecoder().decode([String].self, from: dnsData)
else {
return
}
let dnsData = dnsAddresses.toString().data(using: .utf8)!
let dnsArray = try! JSONDecoder().decode([String].self, from: dnsData)
delegate?.onSetInterfaceConfig(
tunnelAddressIPv4: tunnelAddressIPv4.toString(),
@@ -64,11 +58,6 @@ public class CallbackHandler {
)
}
func onTunnelReady() {
logger.log("CallbackHandler.onTunnelReady")
delegate?.onTunnelReady()
}
func onUpdateRoutes(routeList4: RustString, routeList6: RustString) {
logger.log("CallbackHandler.onUpdateRoutes: \(routeList4) \(routeList6)")
delegate?.onUpdateRoutes(routeList4: routeList4.toString(), routeList6: routeList6.toString())
@@ -84,21 +73,4 @@ public class CallbackHandler {
logger.log("CallbackHandler.onDisconnect: \(error)")
delegate?.onDisconnect(error: error)
}
func setSystemDefaultResolvers(resolvers: [String]) {
logger.log(
"CallbackHandler.setSystemDefaultResolvers: \(resolvers)")
self.systemDefaultResolvers = resolvers
}
func getSystemDefaultResolvers() -> RustString {
logger.log(
"CallbackHandler.getSystemDefaultResolvers: \(self.systemDefaultResolvers)"
)
return try! String(
decoding: JSONEncoder().encode(self.systemDefaultResolvers),
as: UTF8.self
).intoRustString()
}
}

View File

@@ -0,0 +1,57 @@
//
// DeviceMetadata.swift
// Firezone
//
// Created by Jamil Bou Kheir on 2/23/24.
//
import FirezoneKit
import Foundation
#if os(iOS)
import UIKit.UIDevice
#endif
public class DeviceMetadata {
public static func getDeviceName() -> String? {
// Returns a generic device name on iOS 16 and higher
// See https://github.com/firezone/firezone/issues/3034
#if os(iOS)
return UIDevice.current.name
#else
// Fallback to connlib's gethostname()
return nil
#endif
}
public static func getOSVersion() -> String? {
// Returns the OS version
// See https://github.com/firezone/firezone/issues/3034
return ProcessInfo.processInfo.operatingSystemVersionString
}
// Returns the Firezone ID as cached by the application or generates and persists a new one
// if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device
// for upsert and identification in the admin portal.
public static func getOrCreateFirezoneId(logger: AppLogger) -> String {
let fileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id")
do {
return try String(contentsOf: fileURL, encoding: .utf8)
} catch {
// Handle the error if the file doesn't exist or isn't readable
// Recreate the file, save a new UUIDv4, and return it
let newUUIDString = UUID().uuidString
do {
try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8)
} catch {
logger.error(
"\(#function): Could not save firezone-id file \(fileURL.path)! Error: \(error)"
)
}
return newUUIDString
}
}
}

View File

@@ -1,81 +0,0 @@
//
// NetworkResource.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import FirezoneKit
import Foundation
public struct NetworkResource: Decodable {
enum ResourceLocation {
case dns(domain: String)
case cidr(cidrAddress: String)
func toString() -> String {
switch self {
case .dns(let domain): return domain
case .cidr(let cidrAddress): return cidrAddress
}
}
var domain: String? {
switch self {
case .dns(let domain): return domain
case .cidr: return nil
}
}
}
let name: String
let resourceLocation: ResourceLocation
var displayableResource: DisplayableResources.Resource {
DisplayableResources.Resource(name: name, location: resourceLocation.toString())
}
}
// A DNS resource example:
// {
// "type": "dns",
// "address": "app.posthog.com",
// "name": "PostHog",
// }
//
// A CIDR resource example:
// {
// "type": "cidr",
// "address": "10.0.0.0/24",
// "name": "AWS SJC VPC1",
// }
extension NetworkResource {
enum ResourceKeys: String, CodingKey {
case type
case address
case name
}
enum DecodeError: Error {
case invalidType(String)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: ResourceKeys.self)
let name = try container.decode(String.self, forKey: .name)
let type = try container.decode(String.self, forKey: .type)
let resourceLocation: ResourceLocation = try {
switch type {
case "dns":
let domain = try container.decode(String.self, forKey: .address)
return .dns(domain: domain)
case "cidr":
let address = try container.decode(String.self, forKey: .address)
return .cidr(cidrAddress: address)
default:
throw DecodeError.invalidType(type)
}
}()
self.init(name: name, resourceLocation: resourceLocation)
}
}

View File

@@ -9,108 +9,56 @@ import NetworkExtension
import os.log
class NetworkSettings {
// Unchanging values
let tunnelAddressIPv4: String
let tunnelAddressIPv6: String
let dnsAddresses: [String]
// WireGuard has an 80-byte overhead. We could try setting tunnelOverheadBytes
// but that's not a reliable way to calculate how big our packets should be,
// so just use the minimum.
let mtu: NSNumber = 1280
public var routes4: [NEIPv4Route] = []
public var routes6: [NEIPv6Route] = []
// These will only be initialized once and then don't change
private weak var packetTunnelProvider: NEPacketTunnelProvider?
private(set) var logger: AppLogger
// Modifiable values
private(set) var resourceDomains: [String] = []
private(set) var matchDomains: [String] = [""]
public var tunnelAddressIPv4: String?
public var tunnelAddressIPv6: String?
public var dnsAddresses: [String] = []
public var routes4: [NEIPv4Route] = []
public var routes6: [NEIPv6Route] = []
public var matchDomains: [String] = [""]
// To keep track of modifications
public var hasUnappliedChanges: Bool
init(
tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddresses: [String]
) {
self.tunnelAddressIPv4 = tunnelAddressIPv4
self.tunnelAddressIPv6 = tunnelAddressIPv6
self.dnsAddresses = dnsAddresses
self.hasUnappliedChanges = true
init(packetTunnelProvider: PacketTunnelProvider?, logger: AppLogger) {
self.packetTunnelProvider = packetTunnelProvider
self.logger = logger
}
func setResourceDomains(_ resourceDomains: [String]) {
let sortedResourceDomains = resourceDomains.sorted()
if self.resourceDomains != sortedResourceDomains {
self.resourceDomains = sortedResourceDomains
}
self.hasUnappliedChanges = true
}
func setMatchDomains(_ matchDomains: [String]) {
self.matchDomains = matchDomains
self.hasUnappliedChanges = true
}
func apply(
on packetTunnelProvider: NEPacketTunnelProvider?,
logger: AppLogger,
completionHandler: ((Error?) -> Void)?
) {
guard let packetTunnelProvider = packetTunnelProvider else {
logger.error("\(#function): packetTunnelProvider not initialized! This should not happen.")
return
}
guard self.hasUnappliedChanges else {
logger.error("NetworkSettings.apply: No changes to apply")
completionHandler?(nil)
return
}
logger.log("NetworkSettings.apply: Applying network settings")
func apply(completionHandler: (() -> Void)? = nil) {
// We don't really know the connlib gateway IP address at this point, but just using 127.0.0.1 is okay
// because the OS doesn't really need this IP address.
// NEPacketTunnelNetworkSettings taking in tunnelRemoteAddress is probably a bad abstraction caused by
// NEPacketTunnelNetworkSettings inheriting from NETunnelNetworkSettings.
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
let ipv4Settings = NEIPv4Settings(
addresses: [tunnelAddressIPv4], subnetMasks: ["255.255.255.255"])
ipv4Settings.includedRoutes = self.routes4
tunnelNetworkSettings.ipv4Settings = ipv4Settings
let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6], networkPrefixLengths: [128])
ipv6Settings.includedRoutes = self.routes6
tunnelNetworkSettings.ipv6Settings = ipv6Settings
// Set tunnel addresses and routes
let ipv4Settings = NEIPv4Settings(addresses: [tunnelAddressIPv4!], subnetMasks: ["255.255.255.255"])
let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6!], networkPrefixLengths: [128])
let dnsSettings = NEDNSSettings(servers: dnsAddresses)
// Intercept all DNS queries; SplitDNS will be handled by connlib
ipv4Settings.includedRoutes = routes4
ipv6Settings.includedRoutes = routes6
dnsSettings.matchDomains = matchDomains
dnsSettings.matchDomainsNoSearch = true
tunnelNetworkSettings.ipv4Settings = ipv4Settings
tunnelNetworkSettings.ipv6Settings = ipv6Settings
tunnelNetworkSettings.dnsSettings = dnsSettings
tunnelNetworkSettings.mtu = mtu
self.hasUnappliedChanges = false
logger.log("Attempting to set network settings")
packetTunnelProvider.setTunnelNetworkSettings(tunnelNetworkSettings) { error in
packetTunnelProvider?.setTunnelNetworkSettings(tunnelNetworkSettings) { error in
if let error = error {
logger.error("NetworkSettings.apply: Error: \(error)")
} else {
guard !self.hasUnappliedChanges else {
// Changes were made while the packetTunnelProvider was setting the network settings
logger.log(
"""
NetworkSettings.apply:
Applying changes made to network settings while we were applying the network settings
""")
self.apply(on: packetTunnelProvider, logger: logger, completionHandler: completionHandler)
return
}
logger.log("NetworkSettings.apply: Applied successfully")
self.logger.error(
"\(#function): Error occurred while applying network settings! Error: \(error.localizedDescription)"
)
}
completionHandler?(error)
completionHandler?()
}
}
}
@@ -154,6 +102,8 @@ enum IPv4SubnetMaskLookup {
]
}
// Route convenience helpers. Data is from connlib and guaranteed to be valid.
// Otherwise, we should crash and learn about it.
extension NetworkSettings {
struct Cidr: Codable {
let address: String

View File

@@ -12,11 +12,10 @@ import os
enum PacketTunnelProviderError: Error {
case savedProtocolConfigurationIsInvalid(String)
case tokenNotFoundInKeychain
case couldNotSetNetworkSettings
}
class PacketTunnelProvider: NEPacketTunnelProvider {
let logger = AppLogger(process: .tunnel, folderURL: SharedAccess.tunnelLogFolderURL)
let logger = AppLogger(category: .tunnel, folderURL: SharedAccess.tunnelLogFolderURL)
private var adapter: Adapter?
@@ -26,57 +25,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
) {
logger.log("\(#function)")
guard let controlPlaneURLString = protocolConfiguration.serverAddress else {
logger.error("serverAddress is missing")
self.handleTunnelShutdown(
dueTo: .badTunnelConfiguration,
errorMessage: "serverAddress is missing")
guard let apiURL = protocolConfiguration.serverAddress,
let tokenRef = protocolConfiguration.passwordReference,
let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)?
.providerConfiguration as? [String: String],
let logFilter = providerConfiguration[TunnelStoreKeys.logFilter]
else {
completionHandler(
PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("serverAddress"))
return
}
guard let tokenRef = protocolConfiguration.passwordReference else {
logger.error("passwordReference is missing")
self.handleTunnelShutdown(
dueTo: .badTunnelConfiguration,
errorMessage: "passwordReference is missing")
completionHandler(
PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("passwordReference"))
return
}
let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration
guard let connlibLogFilter = providerConfig?[TunnelProviderKeys.keyConnlibLogFilter] as? String
else {
logger.error("connlibLogFilter is missing")
self.handleTunnelShutdown(
dueTo: .badTunnelConfiguration,
errorMessage: "connlibLogFilter is missing")
completionHandler(
PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("connlibLogFilter"))
return
}
Task {
let keychain = Keychain()
guard let token = await keychain.load(persistentRef: tokenRef) else {
self.handleTunnelShutdown(
dueTo: .tokenNotFound,
errorMessage: "Token not found in keychain")
logger.error("\(#function): No token found in Keychain")
completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain)
return
}
let adapter = Adapter(
controlPlaneURLString: controlPlaneURLString, token: token, logFilter: connlibLogFilter,
apiURL: apiURL,
token: token,
logFilter: logFilter,
packetTunnelProvider: self)
self.adapter = adapter
do {
try adapter.start { error in
if let error {
self.logger.error("Error in adapter.start: \(error)")
self.logger.error("\(#function): \(error)")
}
completionHandler(error)
}
@@ -86,36 +63,43 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
}
// This can be called by the system, or initiated by connlib.
// When called by the system, we call Adapter.stop() from here.
// When initiated by connlib, we've already called stop() there.
override func stopTunnel(
with reason: NEProviderStopReason, completionHandler: @escaping () -> Void
) {
logger.log("stopTunnel: Reason: \(reason)")
adapter?.stop(reason: reason) {
completionHandler()
#if os(macOS)
// HACK: This is a filthy hack to work around Apple bug 32073323
exit(0)
if case .authenticationCanceled = reason {
do {
// Remove the passwordReference from our configuration so that it's not used again
// if the app is re-launched. There's no good way to send data like this from the
// Network Extension to the GUI, so save it to a file for the GUI to read later.
try String(reason.rawValue).write(to: SharedAccess.providerStopReasonURL, atomically: true, encoding: .utf8)
} catch {
logger.error("\(#function): Couldn't write provider stop reason to file. Notification won't work.")
}
#if os(iOS)
// iOS notifications should be shown from the tunnel process
SessionNotificationHelper.showSignedOutNotificationiOS(logger: self.logger)
#endif
}
// handles both connlib-initiated and user-initiated stops
adapter?.stop()
cancelTunnelWithError(nil)
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
let query = String(data: messageData, encoding: .utf8) ?? ""
adapter?.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) {
displayableResources in
completionHandler?(displayableResources?.toData())
// TODO: Use a message format to allow requesting different types of data.
// This currently assumes we're requesting resources.
override func handleAppMessage(_ hash: Data, completionHandler: ((Data?) -> Void)? = nil) {
adapter?.getResourcesIfVersionDifferentFrom(hash: hash) {
resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8))
}
}
func handleTunnelShutdown(dueTo reason: TunnelShutdownEvent.Reason, errorMessage: String) {
TunnelShutdownEvent.saveToDisk(reason: reason, errorMessage: errorMessage, logger: self.logger)
#if os(iOS)
if reason.action == .signoutImmediately {
SessionNotificationHelper.showSignedOutNotificationiOS(logger: self.logger)
}
#endif
}
}
extension NEProviderStopReason: CustomStringConvertible {
@@ -127,7 +111,7 @@ extension NEProviderStopReason: CustomStringConvertible {
case .noNetworkAvailable: return "No network available"
case .unrecoverableNetworkChange: return "Unrecoverable network change"
case .providerDisabled: return "Provider disabled"
case .authenticationCanceled: return "Authentication cancelled"
case .authenticationCanceled: return "Authentication canceled"
case .configurationFailed: return "Configuration failed"
case .idleTimeout: return "Idle timeout"
case .configurationDisabled: return "Configuration disabled"

View File

@@ -0,0 +1,124 @@
//
// SystemConfiguration.swift
// FirezoneNetworkExtensionmacOS
//
// Created by Jamil Bou Kheir on 2/26/24.
//
import FirezoneKit
import Foundation
import SystemConfiguration
class SystemConfigurationResolvers {
private let logger: AppLogger
private var dynamicStore: SCDynamicStore?
// Arbitrary name for the connection to the store
private let storeName = "dev.firezone.firezone.dns" as CFString
init(logger: AppLogger) {
self.logger = logger
guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil)
else {
logger.error("\(#function): Failed to create dynamic store")
self.dynamicStore = nil
return
}
self.dynamicStore = dynamicStore
}
func getDefaultDNSServers(interfaceName: String?) -> [String] {
if let interfaceName = interfaceName {
return getDefaultDNSServersForInterface(interfaceName)
} else {
return getGlobalDefaultDNSServers()
}
}
/// 1. First, find the service ID that corresponds to the interface we're interested in.
/// We do this by searching the configuration store at "Setup:/Network/Service/<service-id>/Interface"
/// for a matching "InterfaceName".
/// 2. When we get a hit, save the service id we found.
/// 3. The DNS ServerAddresses can be found in two places:
/// * If the user has manually overridden the DNS servers for an interface,
/// they'll be at "Setup:/Network/Service/<service-id>/DNS"
/// * If they haven't, then the DHCP server addresses can be found at
/// State:/Network/Service/<service-id>/DNS
/// 4. We assume manually-set DNS servers take precedence over DHCP ones,
/// so return those if found. Otherwise, return the DHCP ones.
private func getDefaultDNSServersForInterface(_ interfaceName: String) -> [String] {
guard let dynamicStore = dynamicStore
else {
return []
}
let interfaceSearchKey = "Setup:/Network/Service/.*/Interface" as CFString
guard let services = SCDynamicStoreCopyKeyList(dynamicStore, interfaceSearchKey) as? [String]
else {
logger.error("\(#function): Unable to retrieve network services")
return []
}
// Loop over all the services found, checking for the one we want
for service in services {
guard let configInterfaceName = fetch(path: service, key: "DeviceName") as? String,
configInterfaceName == interfaceName
else { continue }
// Extract our serviceId
let serviceId = service.split(separator: "/")[3]
// Try to get any manually-assigned DNS servers
let manualDnsPath = "Setup:/Network/Service/\(serviceId)/DNS"
if let serverAddresses = fetch(path: manualDnsPath, key: "ServerAddresses") as? [String] {
return serverAddresses
}
// None found. Try getting the DHCP ones instead.
let dhcpDnsPath = "State:/Network/Service/\(serviceId)/DNS"
if let serverAddresses = fetch(path: dhcpDnsPath, key: "ServerAddresses") as? [String] {
return serverAddresses
}
}
// Otherwise, we failed
return []
}
private func fetch(path: String, key: String) -> Any? {
guard let dynamicStore = dynamicStore,
let result = SCDynamicStoreCopyValue(dynamicStore, path as CFString),
let value = result[key]
else { return nil }
return value
}
private func getGlobalDefaultDNSServers() -> [String] {
guard let dynamicStore = dynamicStore
else {
return []
}
var dnsServers: [String] = []
// Specify the DNS key to fetch the current DNS servers
let dnsKey = "State:/Network/Global/DNS" as CFString
// Retrieve the current DNS server configuration from the dynamic store
guard let dnsInfo = SCDynamicStoreCopyValue(dynamicStore, dnsKey) as? [String: Any],
let servers = dnsInfo[kSCPropNetDNSServerAddresses as String] as? [String]
else {
self.logger.error("\(#function): Failed to retrieve DNS server information")
return []
}
// Append the retrieved DNS servers to the result array
dnsServers.append(contentsOf: servers)
return dnsServers
}
}

View File

@@ -110,3 +110,12 @@ cd swift/apple
```
rm -rf $HOME/Library/Group\ Containers/47R2M6779T.group.dev.firezone.firezone/Library/Caches/logs/connlib
```
### Clearing the Keychain item
Sometimes it's helpful to be able to test how the app behaves when the keychain
item is missing. You can remove the keychain item with the following command:
```bash
security delete-generic-password -s "dev.firezone.firezone"
```