diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift index fe255a667..82324949a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift @@ -18,47 +18,85 @@ enum SettingsViewError: Error { public final class SettingsViewModel: ObservableObject { @Dependency(\.authStore) private var authStore - @Published var settings: Settings + @Published var accountSettings: AccountSettings + @Published var advancedSettings: AdvancedSettings public var onSettingsSaved: () -> Void = unimplemented() private var cancellables = Set() public init() { - settings = Settings() - load() + accountSettings = AccountSettings() + advancedSettings = AdvancedSettings.defaultValue + loadAccountSettings() + loadAdvancedSettings() } - func load() { + func loadAccountSettings() { Task { authStore.tunnelStore.$tunnelAuthStatus .filter { $0.isInitialized } .receive(on: RunLoop.main) .sink { [weak self] tunnelAuthStatus in guard let self = self else { return } - self.settings = Settings(accountId: tunnelAuthStatus.accountId() ?? "") + self.accountSettings = AccountSettings(accountId: tunnelAuthStatus.accountId() ?? "") } .store(in: &cancellables) } } - func save() { + func saveAccountSettings() { Task { let accountId = await authStore.loginStatus.accountId - if accountId == settings.accountId { + if accountId == accountSettings.accountId { // Not changed + await MainActor.run { + accountSettings.isSavedToDisk = true + } return } - let tunnelAuthStatus: TunnelAuthStatus = await { - if settings.accountId.isEmpty { - return .accountNotSetup - } else { - return await authStore.tunnelAuthStatusForAccount(accountId: settings.accountId) - } - }() - try await authStore.tunnelStore.setAuthStatus(tunnelAuthStatus) - onSettingsSaved() + try await updateTunnelAuthStatus(accountId: accountSettings.accountId) + await MainActor.run { + accountSettings.isSavedToDisk = true + } } } + + func loadAdvancedSettings() { + advancedSettings = authStore.tunnelStore.advancedSettings() ?? AdvancedSettings.defaultValue + } + + func saveAdvancedSettings() { + Task { + guard let authBaseURL = URL(string: advancedSettings.authBaseURLString) else { + fatalError("Saved authBaseURL is invalid") + } + try await authStore.tunnelStore.saveAdvancedSettings(advancedSettings) + await MainActor.run { + advancedSettings.isSavedToDisk = true + } + var isChanged = false + await authStore.setAuthBaseURL(authBaseURL, isChanged: &isChanged) + if isChanged { + try await updateTunnelAuthStatus( + accountId: authStore.tunnelStore.tunnelAuthStatus.accountId() ?? "") + } + } + } + + // updateTunnelAuthStatus: + // When the authBaseURL or the accountId changes, we should update the signed-in-ness. + // This is done by searching the keychain for an entry with the authBaseURL+accountId + // combination. If an entry was found, we consider that entry to mean we're logged in. + func updateTunnelAuthStatus(accountId: String) async throws { + let tunnelAuthStatus: TunnelAuthStatus = await { + if accountId.isEmpty { + return .accountNotSetup + } else { + return await authStore.tunnelAuthStatusForAccount(accountId: accountId) + } + }() + try await authStore.tunnelStore.saveAuthStatus(tunnelAuthStatus) + } } public struct SettingsView: View { @@ -67,7 +105,6 @@ public struct SettingsView: View { @ObservedObject var model: SettingsViewModel @Environment(\.dismiss) var dismiss - let teamIdAllowedCharacterSet: CharacterSet @State private var isExportingLogs = false #if os(iOS) @@ -75,127 +112,337 @@ public struct SettingsView: View { @State private var isPresentingExportLogShareSheet = false #endif + struct PlaceholderText { + static let accountId = "account-id" + static let authBaseURL = "Admin portal base URL" + static let apiURL = "Control plane WebSocket URL" + static let logFilter = "RUST_LOG-style filter string" + } + + struct FootnoteText { + static let forAccount = "Your account ID is provided by your admin" + 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. + """) + } + public init(model: SettingsViewModel) { self.model = model - self.teamIdAllowedCharacterSet = { - var pathAllowed = CharacterSet.urlPathAllowed - pathAllowed.remove("/") - return pathAllowed - }() } public var body: some View { #if os(iOS) - ios + NavigationView { + TabView { + accountTab + .tabItem { + Image(systemName: "person.3.fill") + Text("Account") + } + .badge(model.accountSettings.isValid ? nil : "!") + + advancedTab + .tabItem { + Image(systemName: "slider.horizontal.3") + Text("Advanced") + } + .badge(model.advancedSettings.isValid ? nil : "!") + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + self.saveSettings() + } + .disabled( + (model.accountSettings.isSavedToDisk && model.advancedSettings.isSavedToDisk) + || !model.accountSettings.isValid + || !model.advancedSettings.isValid + ) + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + self.loadSettings() + } + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + } #elseif os(macOS) - mac + VStack { + TabView { + accountTab + .tabItem { + Text("Account") + } + advancedTab + .tabItem { + Text("Advanced") + } + } + .padding(20) + } + .onDisappear(perform: { self.loadSettings() }) #else #error("Unsupported platform") #endif } - #if os(iOS) - private var ios: some View { - NavigationView { - VStack(spacing: 10) { - form - ExportLogsButton(isProcessing: $isExportingLogs) { - self.isExportingLogs = true - Task { - self.logTempZipFileURL = try await createLogZipBundle() - self.isPresentingExportLogShareSheet = true + private var accountTab: some View { + #if os(macOS) + VStack { + Spacer() + Form { + Section( + content: { + HStack(spacing: 15) { + Spacer() + Text("Account ID:") + TextField( + "", + text: Binding( + get: { model.accountSettings.accountId }, + set: { model.accountSettings.accountId = $0 } + ), + prompt: Text(PlaceholderText.accountId) + ) + .frame(maxWidth: 240) + .onSubmit { + self.model.saveAccountSettings() + } + Spacer() + } + }, + footer: { + Text(FootnoteText.forAccount) + .foregroundStyle(.secondary) } + ) + Button( + "Apply", + action: { + self.model.saveAccountSettings() + } + ) + .disabled( + model.accountSettings.isSavedToDisk + || !model.accountSettings.isValid + ) + .padding(.top, 5) + } + Spacer() + } + #elseif os(iOS) + VStack { + Form { + Section( + content: { + HStack(spacing: 15) { + Text("Account ID") + .foregroundStyle(.secondary) + TextField( + PlaceholderText.accountId, + text: Binding( + get: { model.accountSettings.accountId }, + set: { model.accountSettings.accountId = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + }, + header: { Text("Account") }, + footer: { Text(FootnoteText.forAccount) } + ) + } + } + #else + #error("Unsupported platform") + #endif + } + + private var advancedTab: some View { + #if os(macOS) + VStack { + Spacer() + HStack { + Spacer() + Form { + TextField( + "Auth Base URL:", + text: Binding( + get: { model.advancedSettings.authBaseURLString }, + set: { model.advancedSettings.authBaseURLString = $0 } + ), + prompt: Text(PlaceholderText.authBaseURL) + ) + + TextField( + "API URL:", + text: Binding( + get: { model.advancedSettings.apiURLString }, + set: { model.advancedSettings.apiURLString = $0 } + ), + prompt: Text(PlaceholderText.apiURL) + ) + + TextField( + "Log Filter:", + text: Binding( + get: { model.advancedSettings.connlibLogFilterString }, + set: { model.advancedSettings.connlibLogFilterString = $0 } + ), + prompt: Text(PlaceholderText.logFilter) + ) + + Text(FootnoteText.forAdvanced) + .foregroundStyle(.secondary) + + HStack(spacing: 30) { + Button( + "Apply", + action: { + self.model.saveAdvancedSettings() + } + ) + .disabled(model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid) + + Button( + "Reset to Defaults", + action: { + self.restoreAdvancedSettingsToDefaults() + } + ) + .disabled(model.advancedSettings == AdvancedSettings.defaultValue) + } + .padding(.top, 5) } - .sheet(isPresented: $isPresentingExportLogShareSheet) { - if let logfileURL = self.logTempZipFileURL { - ShareSheetView( - localFileURL: logfileURL, - completionHandler: { - self.isPresentingExportLogShareSheet = false - self.isExportingLogs = false - self.logTempZipFileURL = nil - }) - } + .padding(10) + Spacer() + } + Spacer() + HStack { + Spacer() + ExportLogsButton(isProcessing: $isExportingLogs) { + self.exportLogsWithSavePanelOnMac() } Spacer() } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - self.saveButtonTapped() - } - .disabled(!isTeamIdValid(model.settings.accountId)) - } - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - self.cancelButtonTapped() + Spacer() + } + #elseif os(iOS) + VStack { + Form { + Section( + content: { + HStack(spacing: 15) { + Text("Auth Base URL") + .foregroundStyle(.secondary) + TextField( + PlaceholderText.authBaseURL, + text: Binding( + get: { model.advancedSettings.authBaseURLString }, + set: { model.advancedSettings.authBaseURLString = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + HStack(spacing: 15) { + Text("API URL") + .foregroundStyle(.secondary) + TextField( + PlaceholderText.apiURL, + text: Binding( + get: { model.advancedSettings.apiURLString }, + set: { model.advancedSettings.apiURLString = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + HStack(spacing: 15) { + Text("Log Filter") + .foregroundStyle(.secondary) + TextField( + PlaceholderText.logFilter, + text: Binding( + get: { model.advancedSettings.connlibLogFilterString }, + set: { model.advancedSettings.connlibLogFilterString = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + HStack { + Spacer() + Button( + "Reset to Defaults", + action: { + self.restoreAdvancedSettingsToDefaults() + } + ) + .disabled(model.advancedSettings == AdvancedSettings.defaultValue) + Spacer() + } + }, + header: { Text("Advanced Settings") }, + footer: { Text(FootnoteText.forAdvanced) } + ) + Section(header: Text("Logs")) { + HStack { + Spacer() + ExportLogsButton(isProcessing: $isExportingLogs) { + self.isExportingLogs = true + Task { + self.logTempZipFileURL = try await createLogZipBundle() + self.isPresentingExportLogShareSheet = true + } + }.sheet(isPresented: $isPresentingExportLogShareSheet) { + if let logfileURL = self.logTempZipFileURL { + ShareSheetView( + localFileURL: logfileURL, + completionHandler: { + self.isPresentingExportLogShareSheet = false + self.isExportingLogs = false + self.logTempZipFileURL = nil + }) + } + } + Spacer() } } } } - } - #endif - - #if os(macOS) - private var mac: some View { - VStack(spacing: 50) { - form - HStack(spacing: 30) { - Button( - "Cancel", - action: { - self.cancelButtonTapped() - }) - Button( - "Save", - action: { - self.saveButtonTapped() - } - ) - .disabled(!isTeamIdValid(model.settings.accountId)) - } - ExportLogsButton(isProcessing: $isExportingLogs) { - self.exportLogsWithSavePanelOnMac() - } - } - } - #endif - - private var form: some View { - Form { - Section { - FormTextField( - title: "Account ID:", - baseURLString: AppInfoPlistConstants.authBaseURL.absoluteString, - placeholder: "account-id", - text: Binding( - get: { model.settings.accountId }, - set: { model.settings.accountId = $0 } - ) - ) - } - } - .navigationTitle("Settings") - .toolbar { - ToolbarItem(placement: .primaryAction) { - } - } + #endif } - private func isTeamIdValid(_ teamId: String) -> Bool { - !teamId.isEmpty && teamId.unicodeScalars.allSatisfy { teamIdAllowedCharacterSet.contains($0) } - } - - func saveButtonTapped() { - model.save() + func saveSettings() { + model.saveAccountSettings() + model.saveAdvancedSettings() dismiss() } - func cancelButtonTapped() { - model.load() + func loadSettings() { + model.loadAccountSettings() + model.loadAdvancedSettings() 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 @@ -302,15 +549,16 @@ struct FormTextField: View { var body: some View { #if os(iOS) - HStack(spacing: 15) { - Text(title) + VStack(spacing: 10) { + Spacer() + HStack(spacing: 5) { + Text(title) + Spacer() + TextField(baseURLString, text: text, prompt: Text(placeholder)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } Spacer() - TextField(baseURLString, text: text, prompt: Text(placeholder)) - .autocorrectionDisabled() - .multilineTextAlignment(.leading) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - .textInputAutocapitalization(.never) } #else HStack(spacing: 30) { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index a63279322..edc4ee27a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -6,6 +6,62 @@ import Foundation -struct Settings: Codable, Hashable { - var accountId: String = "" +struct AccountSettings { + var accountId: String = "" { + didSet { if oldValue != accountId { isSavedToDisk = false } } + } + + var isSavedToDisk = true + + var isValid: Bool { + !accountId.isEmpty + && accountId.unicodeScalars.allSatisfy { Self.teamIdAllowedCharacterSet.contains($0) } + } + + static let teamIdAllowedCharacterSet: CharacterSet = { + var pathAllowed = CharacterSet.urlPathAllowed + pathAllowed.remove("/") + return pathAllowed + }() +} + +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 + + var isValid: Bool { + URL(string: authBaseURLString) != nil + && URL(string: apiURLString) != nil + && !connlibLogFilterString.isEmpty + } + + static let defaultValue: AdvancedSettings = { + #if DEBUG + AdvancedSettings( + authBaseURLString: "https://app.firez.one/", + apiURLString: "wss://api.firez.one/", + connlibLogFilterString: + "connlib_client_apple=debug,firezone_tunnel=trace,connlib_shared=debug,connlib_client_shared=debug,warn" + ) + #else + AdvancedSettings( + authBaseURLString: "https://app.firezone.dev/", + apiURLString: "wss://api.firezone.dev/", + connlibLogFilterString: + "connlib_client_apple=info,firezone_tunnel=info,connlib_shared=info,connlib_client_shared=info,warn" + ) + #endif + }() + + // Note: To see what the connlibLogFilterString values mean, see: + // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift index 0547bd1b2..fbcc9ccdd 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift @@ -45,7 +45,7 @@ final class AuthStore: ObservableObject { let tunnelStore: TunnelStore - public let authBaseURL: URL + public var authBaseURL: URL private var cancellables = Set() @Published private(set) var loginStatus: LoginStatus @@ -55,6 +55,10 @@ final class AuthStore: ObservableObject { self.authBaseURL = AppInfoPlistConstants.authBaseURL self.loginStatus = .uninitialized + Task { + self.loginStatus = await self.getLoginStatus(from: tunnelStore.tunnelAuthStatus) + } + tunnelStore.$tunnelAuthStatus .sink { [weak self] tunnelAuthStatus in guard let self = self else { return } @@ -71,15 +75,11 @@ final class AuthStore: ObservableObject { return .uninitialized case .accountNotSetup: return .signedOut(accountId: nil) - case .signedOut(let tunnelAuthBaseURL, let tunnelAccountId): - if self.authBaseURL == tunnelAuthBaseURL { - return .signedOut(accountId: tunnelAccountId) - } else { - return .signedOut(accountId: nil) - } + case .signedOut(_, let tunnelAccountId): + return .signedOut(accountId: tunnelAccountId) case .signedIn(let tunnelAuthBaseURL, let tunnelAccountId, let tokenReference): guard self.authBaseURL == tunnelAuthBaseURL else { - return .signedOut(accountId: nil) + return .signedOut(accountId: tunnelAccountId) } let tunnelPortalURLString = self.authURL(accountId: tunnelAccountId).absoluteString guard let tokenAttributes = await keychain.loadAttributes(tokenReference), @@ -100,7 +100,7 @@ final class AuthStore: ObservableObject { authURLString: portalURL.absoluteString, actorName: authResponse.actorName ?? "") let tokenRef = try await keychain.store(authResponse.token, attributes) - try await tunnelStore.setAuthStatus( + try await tunnelStore.saveAuthStatus( .signedIn(authBaseURL: self.authBaseURL, accountId: accountId, tokenReference: tokenRef)) } @@ -143,4 +143,9 @@ final class AuthStore: ObservableObject { func authURL(accountId: String) -> URL { self.authBaseURL.appendingPathComponent(accountId) } + + func setAuthBaseURL(_ authBaseURL: URL, isChanged: inout Bool) { + isChanged = (self.authBaseURL == authBaseURL) + self.authBaseURL = authBaseURL + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift index 5b27a7abb..2fd1b8711 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift @@ -13,17 +13,19 @@ enum TunnelStoreError: Error { case tunnelCouldNotBeStarted } +public struct TunnelProviderKeys { + static let keyAuthBaseURLString = "authBaseURLString" + static let keyAccountId = "accountId" + public static let keyConnlibLogFilter = "connlibLogFilter" +} + final class TunnelStore: ObservableObject { private static let logger = Logger.make(for: TunnelStore.self) static let shared = TunnelStore() - static let keyAuthBaseURLString = "authBaseURLString" - static let keyAccountId = "accountId" - @Published private var tunnel: NETunnelProviderManager? - @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = TunnelAuthStatus( - protocolConfiguration: nil) + @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .tunnelUninitialized @Published private(set) var status: NEVPNStatus { didSet { TunnelStore.logger.info("status changed: \(self.status.description)") } @@ -41,7 +43,7 @@ final class TunnelStore: ObservableObject { init() { self.tunnel = nil - self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil) + self.tunnelAuthStatus = .tunnelUninitialized self.status = .invalid Task { @@ -56,12 +58,11 @@ final class TunnelStore: ObservableObject { if let tunnel = managers.first { Self.logger.log("\(#function): Tunnel already exists") self.tunnel = tunnel - self.tunnelAuthStatus = TunnelAuthStatus( - protocolConfiguration: tunnel.protocolConfiguration as? NETunnelProviderProtocol) + self.tunnelAuthStatus = tunnel.authStatus() } else { let tunnel = NETunnelProviderManager() tunnel.localizedDescription = "Firezone" - tunnel.protocolConfiguration = TunnelAuthStatus.accountNotSetup.toProtocolConfiguration() + tunnel.protocolConfiguration = basicProviderProtocol() try await tunnel.saveToPreferences() Self.logger.log("\(#function): Tunnel created") self.tunnel = tunnel @@ -74,7 +75,7 @@ final class TunnelStore: ObservableObject { } } - func setAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws { + func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws { guard let tunnel = tunnel else { fatalError("Tunnel not initialized yet") } @@ -84,11 +85,46 @@ final class TunnelStore: ObservableObject { if wasConnected { stop() } - tunnel.protocolConfiguration = tunnelAuthStatus.toProtocolConfiguration() - try await tunnel.saveToPreferences() + + try await tunnel.saveAuthStatus(tunnelAuthStatus) self.tunnelAuthStatus = tunnelAuthStatus } + func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws { + guard let tunnel = tunnel else { + fatalError("Tunnel not initialized yet") + } + + let wasConnected = + (tunnel.connection.status == .connected || tunnel.connection.status == .connecting) + if wasConnected { + stop() + } + + try await tunnel.saveAdvancedSettings(advancedSettings) + } + + func advancedSettings() -> AdvancedSettings? { + guard let tunnel = tunnel else { + return nil + } + + return tunnel.advancedSettings() + } + + func basicProviderProtocol() -> NETunnelProviderProtocol { + let protocolConfiguration = NETunnelProviderProtocol() + protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map { + "\($0).network-extension" + } + protocolConfiguration.serverAddress = AdvancedSettings.defaultValue.apiURLString + protocolConfiguration.providerConfiguration = [ + TunnelProviderKeys.keyConnlibLogFilter: + AdvancedSettings.defaultValue.connlibLogFilterString + ] + return protocolConfiguration + } + func start() async throws { guard let tunnel = tunnel else { Self.logger.log("\(#function): TunnelStore is not initialized") @@ -101,6 +137,10 @@ final class TunnelStore: ObservableObject { return } + if tunnel.advancedSettings().connlibLogFilterString.isEmpty { + tunnel.setConnlibLogFilter(AdvancedSettings.defaultValue.connlibLogFilterString) + } + tunnel.isEnabled = true try await tunnel.saveToPreferences() try await tunnel.loadFromPreferences() @@ -134,7 +174,7 @@ final class TunnelStore: ObservableObject { session.stopTunnel() if case .signedIn(let authBaseURL, let accountId, let tokenReference) = self.tunnelAuthStatus { - try await setAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId)) + try await saveAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId)) return tokenReference } @@ -259,57 +299,6 @@ enum TunnelAuthStatus { } } - init(protocolConfiguration: NETunnelProviderProtocol?) { - if let protocolConfiguration = protocolConfiguration { - let providerConfig = protocolConfiguration.providerConfiguration - let authBaseURL: URL? = { - guard let urlString = providerConfig?[TunnelStore.keyAuthBaseURLString] as? String else { - return nil - } - return URL(string: urlString) - }() - let accountId = providerConfig?[TunnelStore.keyAccountId] as? String - let tokenRef = protocolConfiguration.passwordReference - if let authBaseURL = authBaseURL, let accountId = accountId { - if let tokenRef = tokenRef { - self = .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef) - } else { - self = .signedOut(authBaseURL: authBaseURL, accountId: accountId) - } - } else { - self = .accountNotSetup - } - } else { - self = .tunnelUninitialized - } - } - - func toProtocolConfiguration() -> NETunnelProviderProtocol { - let protocolConfiguration = NETunnelProviderProtocol() - protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map { - "\($0).network-extension" - } - protocolConfiguration.serverAddress = AppInfoPlistConstants.controlPlaneURL.absoluteString - - switch self { - case .tunnelUninitialized, .accountNotSetup: - break - case .signedOut(let authBaseURL, let accountId): - protocolConfiguration.providerConfiguration = [ - TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString, - TunnelStore.keyAccountId: accountId, - ] - case .signedIn(let authBaseURL, let accountId, let tokenReference): - protocolConfiguration.providerConfiguration = [ - TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString, - TunnelStore.keyAccountId: accountId, - ] - protocolConfiguration.passwordReference = tokenReference - } - - return protocolConfiguration - } - func accountId() -> String? { switch self { case .tunnelUninitialized, .accountNotSetup: @@ -338,3 +327,107 @@ 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 accountId = providerConfig[TunnelProviderKeys.keyAccountId] as? String + let tokenRef = protocolConfiguration.passwordReference + if let authBaseURL = authBaseURL, let accountId = accountId { + if let tokenRef = tokenRef { + return .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef) + } else { + return .signedOut(authBaseURL: authBaseURL, accountId: accountId) + } + } else { + return .accountNotSetup + } + } + return .accountNotSetup + } + + func saveAuthStatus(_ authStatus: TunnelAuthStatus) async throws { + if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration + { + var providerConfig = providerConfiguration + + switch authStatus { + case .tunnelUninitialized, .accountNotSetup: + break + case .signedOut(let authBaseURL, let accountId): + providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString + providerConfig[TunnelProviderKeys.keyAccountId] = accountId + case .signedIn(let authBaseURL, let accountId, let tokenReference): + providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString + providerConfig[TunnelProviderKeys.keyAccountId] = accountId + protocolConfiguration.passwordReference = tokenReference + } + + protocolConfiguration.providerConfiguration = providerConfig + + try await saveToPreferences() + } + } + + func advancedSettings() -> AdvancedSettings { + if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol, + let providerConfig = protocolConfiguration.providerConfiguration + { + let authBaseURLString = + (providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String) + ?? AdvancedSettings.defaultValue.authBaseURLString + let logFilter = + (providerConfig[TunnelProviderKeys.keyConnlibLogFilter] as? String) + ?? AdvancedSettings.defaultValue.connlibLogFilterString + let apiURLString = + protocolConfiguration.serverAddress + ?? AdvancedSettings.defaultValue.apiURLString + + return AdvancedSettings( + authBaseURLString: authBaseURLString, + apiURLString: apiURLString, + connlibLogFilterString: logFilter + ) + } + + return AdvancedSettings.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, + let providerConfiguration = protocolConfiguration.providerConfiguration + { + var providerConfig = providerConfiguration + + providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = + advancedSettings.authBaseURLString + providerConfig[TunnelProviderKeys.keyConnlibLogFilter] = + advancedSettings.connlibLogFilterString + + protocolConfiguration.providerConfiguration = providerConfig + protocolConfiguration.serverAddress = advancedSettings.apiURLString + + try await saveToPreferences() + } + } +} diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 5e4097c18..f0d484453 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -89,25 +89,19 @@ public class Adapter { private var controlPlaneURLString: String private var token: String - // Docs on filter strings: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html - #if DEBUG - private let logFilterString = - "connlib_client_apple=debug,firezone_tunnel=trace,connlib_shared=debug,connlib_client_shared=debug,warn" - #else - private let logFilterString = - "connlib_client_apple=info,firezone_tunnel=info,connlib_shared=info,connlib_client_shared=info,warn" - #endif - + private let logFilter: String private let connlibLogFolderPath: String public init( - controlPlaneURLString: String, token: String, packetTunnelProvider: NEPacketTunnelProvider + controlPlaneURLString: String, token: String, + logFilter: String, packetTunnelProvider: NEPacketTunnelProvider ) { self.controlPlaneURLString = controlPlaneURLString self.token = token self.packetTunnelProvider = packetTunnelProvider self.callbackHandler = CallbackHandler() self.state = .stoppedTunnel + self.logFilter = logFilter self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" } @@ -147,7 +141,7 @@ public class Adapter { self.state = .startingTunnel( session: try WrappedSession.connect( self.controlPlaneURLString, self.token, self.getDeviceId(), self.connlibLogFolderPath, - self.logFilterString, self.callbackHandler), + self.logFilter, self.callbackHandler), onStarted: completionHandler ) } catch let error { @@ -284,7 +278,7 @@ extension Adapter { self.state = .startingTunnel( session: try WrappedSession.connect( controlPlaneURLString, token, self.getDeviceId(), self.connlibLogFolderPath, - logFilterString, + self.logFilter, self.callbackHandler), onStarted: { error in if let error = error { diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 37df7f15f..13c97e08e 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -40,6 +40,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration + + guard let connlibLogFilter = providerConfig?[TunnelProviderKeys.keyConnlibLogFilter] as? String + else { + Self.logger.error("connlibLogFilter is missing") + completionHandler( + PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("connlibLogFilter")) + return + } + Task { let keychain = Keychain() guard let token = await keychain.load(persistentRef: tokenRef) else { @@ -48,7 +58,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } let adapter = Adapter( - controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self) + controlPlaneURLString: controlPlaneURLString, token: token, logFilter: connlibLogFilter, + packetTunnelProvider: self) self.adapter = adapter do { try adapter.start { error in