Files
ios-client/NetbirdKit/NetworkExtensionAdapter.swift
Diego Romar 72ab3a55c9 UI reporting wrong connection state when VPN is running (#48)
* Add method to return the current VPN connection state

By checking if there's a VPN Manager instance from preferences
whose name matches the extension's. If so, it sets the extension's
session with its connection, and returns the current VPN status.
Returns false, otherwise.

* Call loadCurrentConnectionState when app becomes active

This will be used to both report the current connection state properly
and configure the ExtensionAdpater's session before polling.

This doesn't start or create a new VPN manager, it only checks for one
that is already running in case the VPN is connected by the time the app
cold starts.

* Guard active state after async operations to prevent background polling

* Bump version to v0.0.16.2
2026-01-23 17:46:34 -03:00

704 lines
28 KiB
Swift

//
// NetworkExtensionAdapter.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 02.10.23.
//
import Foundation
import NetworkExtension
import SwiftUI
import Combine
import NetBirdSDK
import os
// SSO Listener for config initialization
/// Used to check if SSO is supported and save initial config
class ConfigSSOListener: NSObject, NetBirdSDKSSOListenerProtocol {
var onResult: ((Bool?, Error?) -> Void)?
func onSuccess(_ ssoSupported: Bool) {
onResult?(ssoSupported, nil)
}
func onError(_ error: Error?) {
onResult?(nil, error)
}
}
public class NetworkExtensionAdapter: ObservableObject {
private let logger = Logger(subsystem: "io.netbird.app", category: "NetworkExtensionAdapter")
#if os(tvOS)
static let defaultManagementURL = "https://api.netbird.io"
#endif
var session: NETunnelProviderSession?
var vpnManager: NETunnelProviderManager?
#if os(tvOS)
var extensionID = "io.netbird.app.tv.extension"
var extensionName = "NetBird"
#else
var extensionID = "io.netbird.app.NetbirdNetworkExtension"
var extensionName = "NetBird Network Extension"
#endif
let decoder = PropertyListDecoder()
@Published var timer: Timer
@Published var showBrowser = false
@Published var loginURL: String?
@Published var userCode: String?
private let fetchLock = NSLock()
private var _isFetchingStatus = false
private var isFetchingStatus: Bool {
get { fetchLock.lock(); defer { fetchLock.unlock() }; return _isFetchingStatus }
set { fetchLock.lock(); defer { fetchLock.unlock() }; _isFetchingStatus = newValue }
}
init() {
self.timer = Timer()
self.timer.invalidate()
// Don't configure manager during init - it's a slow system call that blocks app startup.
// Instead, configureManager is called lazily when needed (start(), stop(), etc.)
// This allows the UI to appear immediately on first launch.
}
deinit {
self.timer.invalidate()
}
@MainActor
func start() async {
logger.info("start: ENTRY - beginning VPN start sequence")
do {
logger.info("start: calling configureManager()...")
try await configureManager()
logger.info("start: configureManager() completed, calling loginIfRequired()...")
await loginIfRequired()
logger.info("start: loginIfRequired() completed")
} catch {
logger.error("start: CAUGHT ERROR - \(error.localizedDescription)")
}
logger.info("start: EXIT")
}
private func configureManager() async throws {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
self.vpnManager = manager
} else {
let newManager = createNewManager()
try await newManager.saveToPreferences()
self.vpnManager = newManager
}
self.vpnManager?.isEnabled = true
try await self.vpnManager?.saveToPreferences()
try await self.vpnManager?.loadFromPreferences()
self.session = self.vpnManager?.connection as? NETunnelProviderSession
}
/// Loads an existing VPN manager from preferences and returns the current connection state.
/// This is used on app startup to establish the session for status polling and get the
/// initial connection state, without triggering VPN configuration or starting a connection.
/// Returns the current VPN connection status if a manager was found, nil otherwise.
@MainActor
public func loadCurrentConnectionState() async -> NEVPNStatus? {
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
self.vpnManager = manager
self.session = manager.connection as? NETunnelProviderSession
let status = manager.connection.status
logger.info("loadCurrentConnectionState: Found existing manager, session established, status: \(status.rawValue)")
return status
} else {
logger.info("loadCurrentConnectionState: No existing manager found")
return nil
}
} catch {
logger.error("loadCurrentConnectionState: Error loading managers: \(error.localizedDescription)")
return nil
}
}
private func createNewManager() -> NETunnelProviderManager {
let tunnelProviderProtocol = NETunnelProviderProtocol()
tunnelProviderProtocol.providerBundleIdentifier = self.extensionID
tunnelProviderProtocol.serverAddress = "multiple endpoints"
let newManager = NETunnelProviderManager()
newManager.protocolConfiguration = tunnelProviderProtocol
newManager.localizedDescription = self.extensionName
newManager.isEnabled = true
return newManager
}
public func loginIfRequired() async {
logger.info("loginIfRequired: starting...")
#if os(tvOS)
// On tvOS, try to initialize config from the main app first.
// This is needed because the Network Extension may not have write access
// to the App Group container on tvOS.
logger.info("loginIfRequired: tvOS - calling initializeConfigFromApp()")
await initializeConfigFromApp()
#endif
let needsLogin = self.isLoginRequired()
logger.info("loginIfRequired: isLoginRequired() returned \(needsLogin)")
if needsLogin {
logger.info("loginIfRequired: login required, calling performLogin()")
// Note: For tvOS, config initialization happens in the extension's startTunnel
// before the needsLogin check. The extension has permission to write to App Group.
await performLogin()
} else {
logger.info("loginIfRequired: login NOT required, calling startVPNConnection()")
startVPNConnection()
}
logger.info("loginIfRequired: done")
}
#if os(tvOS)
/// Try to initialize the config file from the main app.
/// On tvOS, shared UserDefaults doesn't work, so we send config via IPC.
/// Settings (Rosenpass, PreSharedKey) are already stored in the config JSON.
private func initializeConfigFromApp() async {
// Check if config exists in main app's UserDefaults
// Note: Shared UserDefaults doesn't work on tvOS between app and extension,
// but we can still use it to store config in the main app
if let configJSON = Preferences.loadConfigFromUserDefaults(), !configJSON.isEmpty {
logger.info("initializeConfigFromApp: Config exists in UserDefaults, sending to extension via IPC")
// Send config to extension via IPC (settings are already in the JSON)
await sendConfigToExtensionAsync(configJSON)
return
}
guard let configPath = Preferences.configFile() else {
logger.error("initializeConfigFromApp: App group container unavailable")
return
}
let fileManager = FileManager.default
// Check if config already exists as a file (unlikely on tvOS but check anyway)
if fileManager.fileExists(atPath: configPath) {
logger.info("initializeConfigFromApp: Config already exists at \(configPath)")
return
}
logger.info("initializeConfigFromApp: No config found, user needs to configure server first")
// Don't automatically create config with default URL - user should go through ServerView
}
/// Async wrapper for sendConfigToExtension
private func sendConfigToExtensionAsync(_ configJSON: String) async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sendConfigToExtension(configJSON) { _ in
continuation.resume()
}
}
}
#endif
public func isLoginRequired() -> Bool {
guard let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() else {
logger.error("isLoginRequired: App group container unavailable - assuming login required")
return true
}
logger.info("isLoginRequired: checking config at \(configPath), state at \(statePath)")
// Debug: Check if files exist and their sizes
let fileManager = FileManager.default
let configExists = fileManager.fileExists(atPath: configPath)
let stateExists = fileManager.fileExists(atPath: statePath)
logger.info("isLoginRequired: configFile exists = \(configExists), stateFile exists = \(stateExists)")
#if os(tvOS)
// On tvOS, the app doesn't have permission to write to App Group container.
// File writes are blocked, so we check UserDefaults instead.
// Config is saved to UserDefaults after successful login.
let hasConfigInUserDefaults = Preferences.hasConfigInUserDefaults()
logger.info("isLoginRequired: tvOS - hasConfigInUserDefaults = \(hasConfigInUserDefaults)")
if !hasConfigInUserDefaults {
// No config in UserDefaults - user definitely needs to login
logger.info("isLoginRequired: tvOS - no config in UserDefaults, login required")
return true
}
// Config exists - but we need to verify with the management server
// that the session is still valid (tokens can expire)
logger.info("isLoginRequired: tvOS - config found, checking with management server...")
// Create a Client and load config from UserDefaults
guard let client = NetBirdSDKNewClient("", "", Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else {
logger.error("isLoginRequired: tvOS - failed to create SDK client")
return true
}
// Load the config from UserDefaults into the client
if let configJSON = Preferences.loadConfigFromUserDefaults() {
do {
try client.setConfigFromJSON(configJSON)
logger.info("isLoginRequired: tvOS - loaded config from UserDefaults into client")
} catch {
logger.error("isLoginRequired: tvOS - failed to load config: \(error.localizedDescription)")
return true
}
} else {
logger.error("isLoginRequired: tvOS - no config JSON in UserDefaults")
return true
}
// Now check with the management server
let result = client.isLoginRequired()
logger.info("isLoginRequired: tvOS - SDK returned \(result)")
return result
#else
if configExists {
if let attrs = try? fileManager.attributesOfItem(atPath: configPath),
let size = attrs[.size] as? Int64 {
logger.debug("isLoginRequired: configFile size = \(size) bytes")
}
}
if stateExists {
if let attrs = try? fileManager.attributesOfItem(atPath: statePath),
let size = attrs[.size] as? Int64 {
logger.debug("isLoginRequired: stateFile size = \(size) bytes")
}
}
guard let client = NetBirdSDKNewClient(configPath, statePath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else {
logger.debug("isLoginRequired: Failed to initialize client")
return true
}
let result = client.isLoginRequired()
print("isLoginRequired: SDK returned \(result)")
return result
#endif
}
class ObserverBox {
var observer: NSObjectProtocol?
}
private func performLogin() async {
let loginURLString = await withCheckedContinuation { continuation in
self.login { urlString in
continuation.resume(returning: urlString)
}
}
self.loginURL = loginURLString
self.showBrowser = true
}
public func startVPNConnection() {
logger.info("startVPNConnection: called")
let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO"
logger.info("startVPNConnection: logLevel = \(logLevel)")
let options: [String: NSObject] = ["logLevel": logLevel as NSObject]
guard let session = self.session else {
logger.error("startVPNConnection: ERROR - session is nil!")
return
}
logger.info("startVPNConnection: session exists, calling startVPNTunnel...")
do {
try session.startVPNTunnel(options: options)
logger.info("startVPNConnection: startVPNTunnel() returned successfully")
} catch let error {
logger.error("startVPNConnection: ERROR - startVPNTunnel failed: \(error.localizedDescription)")
}
}
func stop() -> Void {
self.vpnManager?.connection.stopVPNTunnel()
}
func login(completion: @escaping (String) -> Void) {
if self.session == nil {
logger.error("login: No session available for login")
return
}
do {
// Use LoginTV for tvOS to force device auth flow
#if os(tvOS)
let messageString = "LoginTV"
#else
let messageString = "Login"
#endif
if let messageData = messageString.data(using: .utf8) {
// Send the message to the network extension
try self.session!.sendProviderMessage(messageData) { response in
if let response = response {
#if os(tvOS)
// For tvOS, decode DeviceAuthResponse struct
do {
let authResponse = try self.decoder.decode(DeviceAuthResponse.self, from: response)
DispatchQueue.main.async {
self.userCode = authResponse.userCode
}
completion(authResponse.url)
} catch {
print("login: Failed to decode DeviceAuthResponse - \(error)")
// Fallback to plain string for backwards compatibility
if let string = String(data: response, encoding: .utf8) {
completion(string)
}
}
#else
if let string = String(data: response, encoding: .utf8) {
completion(string)
}
#endif
return
}
}
} else {
print("Error converting message to Data")
}
} catch {
print("error when performing network extension action")
}
}
/// Check if login is complete by asking the Network Extension directly
/// This is more reliable than isLoginRequired() because it queries the same SDK client
/// that is actually performing the login
func checkLoginComplete(completion: @escaping (Bool) -> Void) {
guard let session = self.session else {
logger.error("checkLoginComplete: No session available")
completion(false)
return
}
let messageString = "IsLoginComplete"
guard let messageData = messageString.data(using: .utf8) else {
print("checkLoginComplete: Failed to encode message")
completion(false)
return
}
do {
try session.sendProviderMessage(messageData) { response in
if let response = response {
do {
let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response)
print("checkLoginComplete: result=\(diagnostic.isComplete), isExecuting=\(diagnostic.isExecuting), loginRequired=\(diagnostic.loginRequired), configExists=\(diagnostic.configExists), stateExists=\(diagnostic.stateExists), lastResult=\(diagnostic.lastResult), lastError=\(diagnostic.lastError)")
completion(diagnostic.isComplete)
} catch {
print("checkLoginComplete: Failed to decode LoginDiagnostics - \(error)")
completion(false)
}
} else {
print("checkLoginComplete: No response from extension")
completion(false)
}
}
} catch {
print("checkLoginComplete: Failed to send message - \(error)")
completion(false)
}
}
/// Check if there's a login error from the extension
/// Returns the error message via completion handler, or nil if no error
func checkLoginError(completion: @escaping (String?) -> Void) {
guard let session = self.session else {
completion(nil)
return
}
let messageString = "IsLoginComplete"
guard let messageData = messageString.data(using: .utf8) else {
completion(nil)
return
}
do {
try session.sendProviderMessage(messageData) { response in
if let response = response {
do {
let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response)
// Only report error if lastResult is "error" and there's an actual error message
if diagnostic.lastResult == "error" && !diagnostic.lastError.isEmpty {
// Make the error message more user-friendly
var friendlyError = diagnostic.lastError
if diagnostic.lastError.contains("no peer auth method provided") {
friendlyError = "This server doesn't support device code authentication. Please use a setup key instead."
} else if diagnostic.lastError.contains("expired") || diagnostic.lastError.contains("token") {
friendlyError = "The device code has expired. Please try again."
} else if diagnostic.lastError.contains("denied") || diagnostic.lastError.contains("rejected") {
friendlyError = "Authentication was denied. Please try again."
}
completion(friendlyError)
return
}
completion(nil)
} catch {
print("checkLoginError: Failed to decode LoginDiagnostics - \(error)")
completion(nil)
}
} else {
completion(nil)
}
}
} catch {
completion(nil)
}
}
func getRoutes(completion: @escaping (RoutesSelectionDetails) -> Void) {
guard let session = self.session else {
let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
completion(defaultStatus)
return
}
let messageString = "GetRoutes"
if let messageData = messageString.data(using: .utf8) {
do {
try session.sendProviderMessage(messageData) { response in
if let response = response {
do {
let decodedStatus = try self.decoder.decode(RoutesSelectionDetails.self, from: response)
completion(decodedStatus)
return
} catch {
print("Failed to decode route selection details.")
}
} else {
let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
completion(defaultStatus)
return
}
}
} catch {
print("Failed to send Provider message")
}
} else {
print("Error converting message to Data")
}
}
func selectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) {
guard let session = self.session else {
return
}
let messageString = "Select-\(id)"
if let messageData = messageString.data(using: .utf8) {
do {
try session.sendProviderMessage(messageData) { response in
let routes = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
completion(routes)
}
} catch {
print("Failed to send Provider message")
}
} else {
print("Error converting message to Data")
}
}
func deselectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) {
guard let session = self.session else {
return
}
let messageString = "Deselect-\(id)"
if let messageData = messageString.data(using: .utf8) {
do {
try session.sendProviderMessage(messageData) { response in
let routes = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
completion(routes)
}
} catch {
print("Failed to send Provider message")
}
} else {
print("Error converting message to Data")
}
}
func fetchData(completion: @escaping (StatusDetails) -> Void) {
guard !isFetchingStatus else {
return
}
let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
guard let session = self.session else {
completion(defaultStatus)
return
}
isFetchingStatus = true
var hasCompleted = false
let completionLock = NSLock()
// This is to make sure completion is called only once
let safeCompletion: (StatusDetails) -> Void = { [weak self] status in
completionLock.lock()
defer { completionLock.unlock() }
guard !hasCompleted else { return }
hasCompleted = true
self?.isFetchingStatus = false
completion(status)
}
// Timeout after 10 seconds to reset fetching status to false
let timeoutWorkItem = DispatchWorkItem {
AppLogger.shared.log("fetchData timed out")
safeCompletion(defaultStatus)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeoutWorkItem)
let messageString = "Status"
if let messageData = messageString.data(using: .utf8) {
do {
try session.sendProviderMessage(messageData) { [weak self] response in
timeoutWorkItem.cancel()
guard let response = response else {
safeCompletion(defaultStatus)
return
}
do {
let decodedStatus = try self?.decoder.decode(StatusDetails.self, from: response)
safeCompletion(decodedStatus ?? defaultStatus)
} catch {
AppLogger.shared.log("Failed to decode status details: \(error)")
safeCompletion(defaultStatus)
}
}
} catch {
timeoutWorkItem.cancel()
AppLogger.shared.log("Failed to send Provider message")
safeCompletion(defaultStatus)
}
} else {
timeoutWorkItem.cancel()
AppLogger.shared.log("Error converting message to Data")
safeCompletion(defaultStatus)
}
}
func startTimer(completion: @escaping (StatusDetails) -> Void) {
self.timer.invalidate()
self.fetchData(completion: completion)
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: { _ in
self.fetchData(completion: completion)
})
}
func stopTimer() {
self.timer.invalidate()
}
#if os(tvOS)
/// Send config JSON to the Network Extension via IPC
/// On tvOS, shared UserDefaults doesn't work between app and extension,
/// so we transfer config directly via IPC
func sendConfigToExtension(_ configJSON: String, completion: ((Bool) -> Void)? = nil) {
guard let session = self.session else {
logger.warning("sendConfigToExtension: No session available")
completion?(false)
return
}
let messageString = "SetConfig:\(configJSON)"
guard let messageData = messageString.data(using: .utf8) else {
logger.error("sendConfigToExtension: Failed to convert message to Data")
completion?(false)
return
}
do {
try session.sendProviderMessage(messageData) { response in
if let response = response,
let responseString = String(data: response, encoding: .utf8),
responseString == "true" {
self.logger.info("sendConfigToExtension: Config sent successfully")
completion?(true)
} else {
self.logger.warning("sendConfigToExtension: Extension did not confirm receipt")
completion?(false)
}
}
} catch {
logger.error("sendConfigToExtension: Failed to send message: \(error.localizedDescription)")
completion?(false)
}
}
/// Clear extension-local config on logout
/// This ensures the extension doesn't have stale credentials after logout
func clearExtensionConfig(completion: ((Bool) -> Void)? = nil) {
guard let session = self.session else {
logger.warning("clearExtensionConfig: No session available")
completion?(false)
return
}
let messageString = "ClearConfig"
guard let messageData = messageString.data(using: .utf8) else {
logger.error("clearExtensionConfig: Failed to convert message to Data")
completion?(false)
return
}
do {
try session.sendProviderMessage(messageData) { response in
if let response = response,
let responseString = String(data: response, encoding: .utf8),
responseString == "true" {
self.logger.info("clearExtensionConfig: Extension config cleared successfully")
completion?(true)
} else {
self.logger.warning("clearExtensionConfig: Extension did not confirm clearing")
completion?(false)
}
}
} catch {
logger.error("clearExtensionConfig: Failed to send message: \(error.localizedDescription)")
completion?(false)
}
}
#endif
func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) {
Task {
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
completion(manager.connection.status)
}
} catch {
print("Error loading from preferences: \(error)")
}
}
}
}