diff --git a/ios/NebulaNetworkExtension/PacketTunnelProvider.swift b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift index 944624b..7151856 100644 --- a/ios/NebulaNetworkExtension/PacketTunnelProvider.swift +++ b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift @@ -116,7 +116,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } self.nebula!.start() - self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate) + await self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate) } private func handleDNUpdate(newSite: Site) { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 7ed9938..2df78a2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,8 +1,8 @@ -import UIKit @preconcurrency import Flutter import MobileNebula import NetworkExtension import SwiftyJSON +import UIKit enum ChannelName { static let vpn = "net.defined.mobileNebula/NebulaVpnService" @@ -18,136 +18,153 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { private let apiClient = APIClient() private var sites: Sites? private var ui: FlutterMethodChannel? - - + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) - - - dnUpdater.updateAllLoop { site in - // Signal the site has changed in case the current site details screen is active - let container = self.sites?.getContainer(id: site.id) - if (container != nil) { - // Update references to the site with the new site config - container!.site = site - container!.updater.update(connected: site.connected ?? false, replaceSite: site) + + Task.detached { + await self.dnUpdater.updateAllLoop { [weak self] site in + // Signal the site has changed in case the current site details screen is active + let container = self?.sites?.getContainer(id: site.id) + if container != nil { + // Update references to the site with the new site config + container!.site = site + container!.updater.update(connected: site.connected ?? false, replaceSite: site) + } + + // Send the refresh sites command on the main thread + DispatchQueue.main.async { + // Signal to the main screen to reload + self?.ui?.invokeMethod("refreshSites", arguments: nil) + } + } - - // Signal to the main screen to reload - self.ui?.invokeMethod("refreshSites", arguments: nil) } - + guard let controller = window?.rootViewController as? FlutterViewController else { fatalError("rootViewController is not type FlutterViewController") } - + sites = Sites(messenger: controller.binaryMessenger) ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger) - - ui!.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + + ui!.setMethodCallHandler({ (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in switch call.method { case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result) case "nebula.verifyCertAndKey": return self.nebulaVerifyCertAndKey(call: call, result: result) - + case "dn.enroll": return self.dnEnroll(call: call, result: result) - + case "listSites": return self.listSites(result: result) case "deleteSite": return self.deleteSite(call: call, result: result) case "saveSite": return self.saveSite(call: call, result: result) case "startSite": return self.startSite(call: call, result: result) case "stopSite": return self.stopSite(call: call, result: result) - - case "active.listHostmap": self.vpnRequest(command: "listHostmap", arguments: call.arguments, result: result) - case "active.listPendingHostmap": self.vpnRequest(command: "listPendingHostmap", arguments: call.arguments, result: result) - case "active.getHostInfo": self.vpnRequest(command: "getHostInfo", arguments: call.arguments, result: result) - case "active.setRemoteForTunnel": self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result) - case "active.closeTunnel": self.vpnRequest(command: "closeTunnel", arguments: call.arguments, result: result) - + + case "active.listHostmap": + self.vpnRequest(command: "listHostmap", arguments: call.arguments, result: result) + case "active.listPendingHostmap": + self.vpnRequest(command: "listPendingHostmap", arguments: call.arguments, result: result) + case "active.getHostInfo": + self.vpnRequest(command: "getHostInfo", arguments: call.arguments, result: result) + case "active.setRemoteForTunnel": + self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result) + case "active.closeTunnel": + self.vpnRequest(command: "closeTunnel", arguments: call.arguments, result: result) + default: result(FlutterMethodNotImplemented) } }) - + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - + func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) { - guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } - guard let certs = args["certs"] else { return result(MissingArgumentError(message: "certs is a required argument")) } - + guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) } + guard let certs = args["certs"] else { + return result(MissingArgumentError(message: "certs is a required argument")) + } + var err: NSError? let json = MobileNebulaParseCerts(certs, &err) - if (err != nil) { - return result(CallFailedError(message: "Error while parsing certificate(s)", details: err!.localizedDescription)) + if err != nil { + return result( + CallFailedError(message: "Error while parsing certificate(s)", details: err!.localizedDescription)) } - + return result(json) } - + func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) { - guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } - guard let cert = args["cert"] else { return result(MissingArgumentError(message: "cert is a required argument")) } + guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) } + guard let cert = args["cert"] else { + return result(MissingArgumentError(message: "cert is a required argument")) + } guard let key = args["key"] else { return result(MissingArgumentError(message: "key is a required argument")) } - + var err: NSError? var validd: ObjCBool = false let valid = MobileNebulaVerifyCertAndKey(cert, key, &validd, &err) - if (err != nil) { - return result(CallFailedError(message: "Error while verifying certificate and private key", details: err!.localizedDescription)) + if err != nil { + return result( + CallFailedError( + message: "Error while verifying certificate and private key", details: err!.localizedDescription)) } - + return result(valid) } - + func nebulaGenerateKeyPair(result: FlutterResult) { var err: NSError? let kp = MobileNebulaGenerateKeyPair(&err) - if (err != nil) { - return result(CallFailedError(message: "Error while generating key pairs", details: err!.localizedDescription)) + if err != nil { + return result( + CallFailedError(message: "Error while generating key pairs", details: err!.localizedDescription)) } - + return result(kp) } - + func nebulaRenderConfig(call: FlutterMethodCall, result: FlutterResult) { guard let config = call.arguments as? String else { return result(NoArgumentsError()) } - + var err: NSError? let yaml = MobileNebulaRenderConfig(config, "", &err) - if (err != nil) { + if err != nil { return result(CallFailedError(message: "Error while rendering config", details: err!.localizedDescription)) } - + return result(yaml) } - + func dnEnroll(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let code = call.arguments as? String else { return result(NoArgumentsError()) } - + do { let site = try apiClient.enroll(code: code) - + let oldSite = self.sites?.getSite(id: site.id) site.save(manager: oldSite?.manager) { error in - if (error != nil) { + if error != nil { return result(CallFailedError(message: "Failed to enroll", details: error!.localizedDescription)) } - + result(nil) } } catch { return result(CallFailedError(message: "Error from DN api", details: error.localizedDescription)) } } - + func listSites(result: @escaping FlutterResult) { - self.sites?.loadSites { (sites, err) -> () in - if (err != nil) { + self.sites?.loadSites { (sites, err) -> Void in + if err != nil { return result(CallFailedError(message: "Failed to load site list", details: err!.localizedDescription)) } @@ -157,30 +174,30 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { result(ret) } } - + func deleteSite(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let id = call.arguments as? String else { return result(NoArgumentsError()) } //TODO: stop the site if its running currently self.sites?.deleteSite(id: id) { error in - if (error != nil) { + if error != nil { result(CallFailedError(message: "Failed to delete site", details: error!.localizedDescription)) } - + result(nil) } } - + func saveSite(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let json = call.arguments as? String else { return result(NoArgumentsError()) } guard let data = json.data(using: .utf8) else { return result(NoArgumentsError()) } - + guard let site = try? JSONDecoder().decode(IncomingSite.self, from: data) else { return result(NoArgumentsError()) } - + let oldSite = self.sites?.getSite(id: site.id) site.save(manager: oldSite?.manager) { error in - if (error != nil) { + if error != nil { return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription)) } @@ -189,68 +206,71 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { } } } - - func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } - guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } - -#if targetEnvironment(simulator) - let updater = self.sites?.getUpdater(id: id) - updater?.update(connected: true) -#else - let container = self.sites?.getContainer(id: id) - let manager = container?.site.manager - manager?.loadFromPreferences{ error in - //TODO: Handle load error - // This is silly but we need to enable the site each time to avoid situations where folks have multiple sites - manager?.isEnabled = true - manager?.saveToPreferences{ error in + func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } + + #if targetEnvironment(simulator) + let updater = self.sites?.getUpdater(id: id) + updater?.update(connected: true) + #else + let container = self.sites?.getContainer(id: id) + let manager = container?.site.manager + + manager?.loadFromPreferences { error in //TODO: Handle load error - manager?.loadFromPreferences{ error in + // This is silly but we need to enable the site each time to avoid situations where folks have multiple sites + manager?.isEnabled = true + manager?.saveToPreferences { error in //TODO: Handle load error - do { - container?.updater.startFunc = {() -> Void in - return self.vpnRequest(command: "start", arguments: args, result: result) + manager?.loadFromPreferences { error in + //TODO: Handle load error + do { + container?.updater.startFunc = { () -> Void in + return self.vpnRequest(command: "start", arguments: args, result: result) + } + try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)]) + } catch { + return result( + CallFailedError(message: "Could not start site", details: error.localizedDescription)) } - try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)]) - } catch { - return result(CallFailedError(message: "Could not start site", details: error.localizedDescription)) } } } - } -#endif + #endif } - + func stopSite(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) } guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } -#if targetEnvironment(simulator) - let updater = self.sites?.getUpdater(id: id) - updater?.update(connected: false) - -#else - let manager = self.sites?.getSite(id: id)?.manager - manager?.loadFromPreferences{ error in - //TODO: Handle load error - - manager?.connection.stopVPNTunnel() - return result(nil) - } -#endif + #if targetEnvironment(simulator) + let updater = self.sites?.getUpdater(id: id) + updater?.update(connected: false) + + #else + let manager = self.sites?.getSite(id: id)?.manager + manager?.loadFromPreferences { error in + //TODO: Handle load error + + manager?.connection.stopVPNTunnel() + return result(nil) + } + #endif } - + func vpnRequest(command: String, arguments: Any?, result: @escaping FlutterResult) { - guard let args = arguments as? Dictionary else { return result(NoArgumentsError()) } - guard let id = args["id"] as? String else { return result(MissingArgumentError(message: "id is a required argument")) } + guard let args = arguments as? [String: Any] else { return result(NoArgumentsError()) } + guard let id = args["id"] as? String else { + return result(MissingArgumentError(message: "id is a required argument")) + } let container = sites?.getContainer(id: id) - + if container == nil { // No site for this id return result(nil) } - + if !(container!.site.connected ?? false) { // Site isn't connected, no point in sending a command return result(nil) @@ -258,20 +278,22 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { if let session = container!.site.manager?.connection as? NETunnelProviderSession { do { - try session.sendProviderMessage(try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))) { data in + try session.sendProviderMessage( + try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args))) + ) { data in if data == nil { return result(nil) } - + //print(String(decoding: data!, as: UTF8.self)) guard let res = try? JSONDecoder().decode(IPCResponse.self, from: data!) else { return result(CallFailedError(message: "Failed to decode response")) } - + if res.type == .success { return result(res.message?.object) } - + return result(CallFailedError(message: res.message?.debugDescription ?? "Failed to convert error")) } } catch { @@ -288,7 +310,9 @@ func MissingArgumentError(message: String, details: (any Error)? = nil) -> Flutt return FlutterError(code: "missingArgument", message: message, details: details) } -func NoArgumentsError(message: String? = "no arguments were provided or could not be deserialized", details: (any Error)? = nil) -> FlutterError { +func NoArgumentsError( + message: String? = "no arguments were provided or could not be deserialized", details: (any Error)? = nil +) -> FlutterError { return FlutterError(code: "noArguments", message: message, details: details) } diff --git a/ios/Runner/DNUpdate.swift b/ios/Runner/DNUpdate.swift index cd9ce54..b67d70d 100644 --- a/ios/Runner/DNUpdate.swift +++ b/ios/Runner/DNUpdate.swift @@ -1,50 +1,57 @@ import Foundation import os.log -class DNUpdater { +actor DNUpdater { private let apiClient = APIClient() - private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes + private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater") - - func updateAll(onUpdate: @escaping (Site) -> ()) { - _ = SiteList{ (sites, _) -> () in - // NEVPN seems to force us onto the main thread and we are about to make network calls that - // could block for a while. Push ourselves onto another thread to avoid blocking the UI. - Task.detached(priority: .userInitiated) { - sites?.values.forEach { site in - if (site.connected == true) { - // The vpn service is in charge of updating the currently connected site - return + + func updateAll(onUpdate: @escaping (Site) -> Void) { + _ = SiteList { (sites, _) -> Void in + switch sites + { + case .some(let sites): + // NEVPN seems to force us onto the main thread and we are about to make network calls that + // could block for a while. Push ourselves onto another thread to avoid blocking the UI. + Task.detached(priority: .userInitiated) { + for site in sites.values { + if site.connected == true { + // The vpn service is in charge of updating the currently connected site + return + } + + await self.updateSite(site: site, onUpdate: onUpdate) } - self.updateSite(site: site, onUpdate: onUpdate) } + default: break } + } } - - func updateAllLoop(onUpdate: @escaping (Site) -> ()) { + + func updateAllLoop(onUpdate: @escaping (Site) -> Void) { timer.eventHandler = { self.updateAll(onUpdate: onUpdate) } timer.resume() } - - func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) { + + func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> Void) { timer.eventHandler = { self.updateSite(site: site, onUpdate: onUpdate) } timer.resume() } - - func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) { + + func updateSite(site: Site, onUpdate: @escaping (Site) -> Void) { do { - if (!site.managed) { + if !site.managed { return } - + let credentials = try site.getDNCredentials() - + let newSite: IncomingSite? do { newSite = try apiClient.tryUpdate( @@ -55,33 +62,37 @@ class DNUpdater { trustedKeys: credentials.trustedKeys ) } catch (APIClientError.invalidCredentials) { - if (!credentials.invalid) { + if !credentials.invalid { try site.invalidateDNCredentials() log.notice("Invalidated credentials in site: \(site.name, privacy: .public)") } - + return } - + let siteManager = site.manager - let shouldSaveToManager = siteManager != nil || ProcessInfo().isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)) - + let shouldSaveToManager = + siteManager != nil + || ProcessInfo().isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)) + newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in - if (error != nil) { + if error != nil { self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)") } - + // reload nebula even if we couldn't save the vpn profile onUpdate(Site(incoming: newSite!)) } - - if (credentials.invalid) { + + if credentials.invalid { try site.validateDNCredentials() log.notice("Revalidated credentials in site \(site.name, privacy: .public)") } - + } catch { - log.error("Error while updating \(site.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + log.error( + "Error while updating \(site.name, privacy: .public): \(error.localizedDescription, privacy: .public)") } } }