diff --git a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift index 55c512f0..3799584c 100644 --- a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift +++ b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift @@ -220,9 +220,7 @@ struct iOSConnectionView: View { case .closed: if adapter.loginSucceeded { print("Finish login") - // The SDK just completed the management login, so the extension can - // skip its own needs-login check (one Login RPC) when it starts. - adapter.startVPNConnection(loginVerified: true) + adapter.startVPNConnection() return } // Ambiguous: the user may have cancelled, or closed the SDK's success diff --git a/NetBirdTests/NetworkReconnectionStateTests.swift b/NetBirdTests/NetworkReconnectionStateTests.swift index 2133c9b8..2d426c3c 100644 --- a/NetBirdTests/NetworkReconnectionStateTests.swift +++ b/NetBirdTests/NetworkReconnectionStateTests.swift @@ -296,3 +296,57 @@ final class NetworkReconnectionStateTests: XCTestCase { } } + + +final class ConnectionListenerStartupTests: XCTestCase { + private final class AuthFailedAdapter: NetBirdAdapter { + override func needsLoginCached() -> Bool { true } + } + + private func makeAdapter() throws -> AuthFailedAdapter { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try FileManager.default.removeItem(at: directory) } + return try XCTUnwrap(AuthFailedAdapter( + with: PacketTunnelProviderSettingsManager(with: PacketTunnelProvider()), + configPath: directory.appendingPathComponent("config.json").path, + statePath: directory.appendingPathComponent("state.json").path)) + } + + func testStaleDisconnectIsIgnoredButNewAuthenticationFailureIsDelivered() throws { + let adapter = try makeAdapter() + var loginRequired = 0 + var states: [ClientState] = [] + adapter.onLoginRequired = { loginRequired += 1 } + let listener = ConnectionListener(adapter: adapter, onConnectionChanged: { states.append($0) }) { _ in } + + // The SDK replays the legacy callback before OnStateChanged. + listener.onDisconnected() + listener.onStateChanged(NetBirdSDKClientStateDisconnected) + XCTAssertEqual(loginRequired, 0) + XCTAssertTrue(states.isEmpty) + + listener.onConnecting() + listener.onStateChanged(NetBirdSDKClientStateConnecting) + listener.onDisconnected() + listener.onStateChanged(NetBirdSDKClientStateDisconnected) + XCTAssertEqual(loginRequired, 1) + XCTAssertEqual(states, [.connecting, .disconnected]) + + // Each engine start gets a new listener, even when reusing the adapter. + let nextListener = ConnectionListener(adapter: adapter) { _ in } + nextListener.onDisconnected() + XCTAssertEqual(loginRequired, 1) + } + + func testOfflineStartStillDeliversAuthenticationFailure() throws { + let adapter = try makeAdapter() + var loginRequired = 0 + adapter.onLoginRequired = { loginRequired += 1 } + let listener = ConnectionListener(adapter: adapter) { _ in } + // NoNetwork has no legacy onConnecting callback. + listener.onStateChanged(NetBirdSDKClientStateNoNetwork) + listener.onDisconnected() + XCTAssertEqual(loginRequired, 1) + } +} diff --git a/NetbirdKit/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift index a9b1fea3..73a483ba 100644 --- a/NetbirdKit/ConnectionListener.swift +++ b/NetbirdKit/ConnectionListener.swift @@ -14,6 +14,9 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { private let onConnectionChanged: ((ClientState) -> Void)? + private let startLock = NSLock() + private var hasStarted = false + var adapter: NetBirdAdapter init(adapter: NetBirdAdapter, onConnectionChanged: ((ClientState) -> Void)? = nil, completionHandler: @escaping (Error?) -> Void) { @@ -28,6 +31,11 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { func onStateChanged(_ state: Int) { guard let state = ClientState(sdkState: state) else { return } + // ClientStart can be reported as NoNetwork when startup is offline. + if state == .connecting || state == .noNetwork { + onConnecting() + } + if state == .disconnected && !engineHasStarted { return } onConnectionChanged?(state) if adapter.isRestarting && (state == .connecting || state == .disconnecting) { AppLogger.shared.log("onStateChanged: suppressed state=\(state) (isRestarting=true)") @@ -43,9 +51,22 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { } } - func onConnecting() {} + func onConnecting() { + startLock.lock() + hasStarted = true + startLock.unlock() + } + + private var engineHasStarted: Bool { + startLock.lock() + defer { startLock.unlock() } + return hasStarted + } func onDisconnected() { + // Registering a listener synchronously replays the reused client's previous + // disconnected state and auth error. Only the new run may request teardown. + guard engineHasStarted else { return } // Disconnected means Run has stopped; NoNetwork is recoverable. if !adapter.isRestarting && adapter.needsLoginCached() { AppLogger.shared.log("onDisconnected: login required — signalling teardown") diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 73623f93..d5cc7b1c 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -30,13 +30,6 @@ struct GlobalConstants { static let keyWidgetActiveConfigPath = "netbird.widget.activeConfigPath" static let keyWidgetActiveStatePath = "netbird.widget.activeStatePath" - /// startVPNTunnel(options:) key. Set by the main app when it has just verified the - /// login state itself (its own isLoginRequired() check, or a login it just completed), - /// letting the extension skip its duplicate check — each such check is a full Login RPC - /// against the management server. Absent for starts the main app did not initiate - /// (On Demand, widget intent), where the extension must still verify. - static let optionLoginVerified = "loginVerified" - static let configFileName = "netbird.cfg" static let stateFileName = "state.json" static let serverURLFileName = "netbird_server_url" diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 4713c877..dfb23c23 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -350,9 +350,8 @@ public class NetworkExtensionAdapter: ObservableObject { #endif } else { logger.info("loginIfRequired: login NOT required, calling startVPNConnection()") - // isLoginRequired() above just answered this against the management server; - // tell the extension so it doesn't repeat the same Login RPC. - startVPNConnection(loginVerified: true) + // Transport failures leave authentication undecided; the engine retries. + startVPNConnection() } logger.info("loginIfRequired: done") @@ -641,9 +640,7 @@ public class NetworkExtensionAdapter: ObservableObject { // when the user dismisses the success page. if !self.showBrowser { self.logger.info("performLogin: login completed after browser closed - starting VPN") - // The management login just completed here, so the extension - // can skip its own needs-login check (one Login RPC). - self.startVPNConnection(loginVerified: true) + self.startVPNConnection() } } } @@ -923,20 +920,12 @@ public class NetworkExtensionAdapter: ObservableObject { #endif /// Starts the tunnel. - /// - Parameter loginVerified: pass true only when this process has just established the - /// login state — either its own isLoginRequired() check returned false, or an - /// interactive login completed successfully. The extension then skips its own - /// needs-login check, which is a full Login RPC against the management server. - /// Defaults to false so any caller that has not verified stays on the safe path. - public func startVPNConnection(loginVerified: Bool = false) { - logger.info("startVPNConnection: called (loginVerified=\(loginVerified))") + public func startVPNConnection() { + logger.info("startVPNConnection: called") let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" logger.info("startVPNConnection: logLevel = \(logLevel)") var options: [String: NSObject] = ["logLevel": logLevel as NSObject] #if os(iOS) - if loginVerified { - options[GlobalConstants.optionLoginVerified] = true as NSObject - } // Pass active profile paths so the extension can reinitialize the adapter // if the profile changed while the extension process was still alive. let configPath = Preferences.configFile() diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index bcf35eba..a68072c5 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -226,37 +226,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - // Skip this check when the main app has already established the login state (its own - // isLoginRequired() call, or a login it just completed) and said so via the start - // options. needsLogin() is a full Login RPC against the management server, so the - // unconditional check duplicated one the other process had just made. Starts the main - // app did not initiate (On Demand, widget intent) carry no flag and still verify here. - // Either way an expired session is caught by the engine: its own login fails with - // PermissionDenied, which drives onLoginRequired and tears the tunnel down. - #if os(iOS) - let loginVerifiedByApp = (options?[GlobalConstants.optionLoginVerified] as? NSNumber)?.boolValue ?? false - #else - let loginVerifiedByApp = false - #endif - if loginVerifiedByApp { - AppLogger.shared.log("startTunnel: login already verified by the main app, skipping needsLogin check") - } - if !loginVerifiedByApp, adapter.needsLogin() { - signalLoginRequired() - // Clear any transitioning widget state so the login button appears immediately - // instead of waiting for the snap-back window to expire. - updateWidgetStatus("disconnected") - // Return the error immediately so iOS tears down the tunnel interface at once. - // A deferred completionHandler keeps the tunnel interface alive (black-hole state) - // and intercepts all network traffic — including ASWebAuthenticationSession requests - // to the OAuth server — causing "Page not found" during re-auth with On Demand enabled. - completeTunnelStart(with: NSError( - domain: "io.netbird.NetbirdNetworkExtension", - code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Login required."] - )) - return - } + // The engine retries transport failures and reports actual authentication + // denial. A duplicate preflight would mistake an outage for expired login. // Wire up the login-required callback so the connection listener can tear the // tunnel down if the auth session expires mid-session (token expires while the diff --git a/netbird-core b/netbird-core index 3fb1f276..24352355 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 3fb1f27681d651c221ded8c783123c517c260596 +Subproject commit 2435235589e8304b57a9b6a1ec19944ac2322826