mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
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:
17
rust/Cargo.lock
generated
17
rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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)!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)!
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//
|
||||
// FirezoneError.swift
|
||||
// (c) 2024 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FirezoneError: Error {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
57
swift/apple/FirezoneNetworkExtension/DeviceMetadata.swift
Normal file
57
swift/apple/FirezoneNetworkExtension/DeviceMetadata.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user