Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions NetBird/Source/App/Views/iOS/iOSConnectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions NetBirdTests/NetworkReconnectionStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
23 changes: 22 additions & 1 deletion NetbirdKit/ConnectionListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)")
Expand All @@ -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")
Expand Down
7 changes: 0 additions & 7 deletions NetbirdKit/GlobalConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 5 additions & 16 deletions NetbirdKit/NetworkExtensionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 2 additions & 31 deletions NetbirdNetworkExtension/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// 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
Expand Down
2 changes: 1 addition & 1 deletion netbird-core
Submodule netbird-core updated 966 files
Loading