Skip to content

Commit

Permalink
Handle managed nebula updates correctly (#186)
Browse files Browse the repository at this point in the history
This fixes a few issues:

1) When updates are made, we will no longer create duplicate VPN profiles, rather we will update existing ones.
2) We will correctly update an existing profile when the site is running and an update is received, rather than attempting to create a new profile, which failed due to permissions errors.  
3) We will always reload nebula, even if we can't successfully save the VPN profile.
4) The default polling interval of 15 minutes is restored (previously set to 30 seconds during testing).

So far in manual testing I've confirmed that I do not lose the tunnel to my lighthouse even after the original 30 minute expiration of a certificate.  This confirms that reloads are occurring correctly.  Additionally, duplicate sites are not created when updates occur while the site is disconnected.
  • Loading branch information
IanVS authored Oct 18, 2024
1 parent fb66430 commit e58078f
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 65 deletions.
140 changes: 79 additions & 61 deletions ios/NebulaNetworkExtension/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import MobileNebula
import os.log
import SwiftyJSON

enum VPNStartError: Error {
case noManagers
case couldNotFindManager
case noTunFileDescriptor
case noProviderConfig
}

class PacketTunnelProvider: NEPacketTunnelProvider {
private var networkMonitor: NWPathMonitor?

Expand All @@ -13,47 +20,46 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
private var didSleep = false
private var cachedRouteDescription: String?

override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
override func startTunnel(options: [String : NSObject]? = nil) async throws {
// There is currently no way to get initialization errors back to the UI via completionHandler here
// `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept
if options?["expectStart"] != nil {
// The system completion handler must be called before IPC will work
completionHandler(nil)
// startTunnel must complete before IPC will work
return
}

// VPN is being booted out of band of the UI. Use the system completion handler as there will be nothing to route initialization errors to but we still need to report
// success/fail by the presence of an error or nil
start(completionHandler: completionHandler)
try await start()
}

private func start(completionHandler: @escaping (Error?) -> Void) {
let proto = self.protocolConfiguration as! NETunnelProviderProtocol
private func start() async throws {
var manager: NETunnelProviderManager?
var config: Data
var key: String


manager = try await self.findManager()

guard let foundManager = manager else {
throw VPNStartError.couldNotFindManager
}

do {
site = try Site(proto: proto)
config = try site!.getConfig()
self.site = try Site(manager: foundManager)
config = try self.site!.getConfig()
} catch {
//TODO: need a way to notify the app
log.error("Failed to render config from vpn object")
return completionHandler(error)
self.log.error("Failed to render config from vpn object")
throw error
}

let _site = site!

do {
key = try _site.getKey()
} catch {
return completionHandler(error)
}
let _site = self.site!
key = try _site.getKey()

let fileDescriptor = tunnelFileDescriptor
if fileDescriptor == nil {
return completionHandler("Unable to locate the tun file descriptor")
guard let fileDescriptor = self.tunnelFileDescriptor else {
throw VPNStartError.noTunFileDescriptor
}
let tunFD = Int(fileDescriptor!)
let tunFD = Int(fileDescriptor)

// This is set to 127.0.0.1 because it has to be something..
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
Expand All @@ -62,42 +68,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
var err: NSError?
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
if (err != nil) {
return completionHandler(err!)
throw err!
}
tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
var routes: [NEIPv4Route] = [NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)]

// Add our unsafe routes
_site.unsafeRoutes.forEach { unsafeRoute in
try _site.unsafeRoutes.forEach { unsafeRoute in
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
if (err != nil) {
return completionHandler(err!)
throw err!
}
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
}

tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber

self.setTunnelNetworkSettings(tunnelNetworkSettings, completionHandler: {(error:Error?) in
if (error != nil) {
return completionHandler(error!)
}

var err: NSError?
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &err)
self.startNetworkMonitor()
try await self.setTunnelNetworkSettings(tunnelNetworkSettings)
var nebulaErr: NSError?
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &nebulaErr)
self.startNetworkMonitor()

if err != nil {
self.log.error("We had an error starting up: \(err, privacy: .public)")
return completionHandler(err!)
}

self.nebula!.start()
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)

completionHandler(nil)
})
if nebulaErr != nil {
self.log.error("We had an error starting up: \(nebulaErr, privacy: .public)")
throw nebulaErr!
}

self.nebula!.start()
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)
}

private func handleDNUpdate(newSite: Site) {
Expand All @@ -116,6 +115,30 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// completionHandler()
// }

private func findManager() async throws -> NETunnelProviderManager {
let targetProtoConfig = self.protocolConfiguration as? NETunnelProviderProtocol
guard let targetProviderConfig = targetProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
let targetID = targetProviderConfig["id"] as? String

// Load vpn configs from system, and find the manager matching the one being started
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
for manager in managers {
let mgrProtoConfig = manager.protocolConfiguration as? NETunnelProviderProtocol
guard let mgrProviderConfig = mgrProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
let id = mgrProviderConfig["id"] as? String
if (id == targetID) {
return manager
}
}

// If we didn't find anything, throw an error
throw VPNStartError.noManagers
}

private func startNetworkMonitor() {
networkMonitor = NWPathMonitor()
networkMonitor!.pathUpdateHandler = self.pathUpdate
Expand Down Expand Up @@ -160,38 +183,33 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return str.sorted().joined(separator: ", ")
}

override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) {
override func handleAppMessage(_ data: Data) async -> Data? {
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else {
log.error("Failed to decode IPCRequest from network extension")
return
return nil
}

var error: Error?
var data: JSON?

// start command has special treatment due to needing to call two completers
if call.command == "start" {
self.start() { error in
// Notify the UI if we have a completionHandler
if completionHandler != nil {
if error == nil {
// No response data, this is expected on a clean start
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))

} else {
// We failed, notify and shutdown
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error!.localizedDescription))))
self.cancelTunnelWithError(error)
}
do {
try await self.start()
// No response data, this is expected on a clean start
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
} catch {
defer {
self.cancelTunnelWithError(error)
}
return try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error.localizedDescription)))
}
return
}

if nebula == nil {
// Respond with an empty success message in the event a command comes in before we've truly started
log.warning("Received command but do not have a nebula instance")
return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}

//TODO: try catch over all this
Expand All @@ -207,9 +225,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}

if (error != nil) {
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error?.localizedDescription ?? "Unknown error"))))
return try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error?.localizedDescription ?? "Unknown error")))
} else {
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data)))
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data))
}
}

Expand Down
2 changes: 2 additions & 0 deletions ios/NebulaNetworkExtension/Site.swift
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@ struct IncomingSite: Codable {
// Stuff our details in the protocol
let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = "net.defined.mobileNebula.NebulaNetworkExtension";
// WARN: If we stop setting providerConfiguration["id"] here, we'll need to use something else to match
// managers in PacketTunnelProvider.findManager
proto.providerConfiguration = ["id": self.id]
proto.serverAddress = "Nebula"

Expand Down
9 changes: 5 additions & 4 deletions ios/Runner/DNUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import os.log

class DNUpdater {
private let apiClient = APIClient()
private let timer = RepeatingTimer(timeInterval: 30) // 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) -> ()) {
Expand Down Expand Up @@ -63,12 +63,13 @@ class DNUpdater {
return
}

newSite?.save(manager: nil) { error in
newSite?.save(manager: site.manager) { error in
if (error != nil) {
self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)")
} else {
onUpdate(Site(incoming: newSite!))
}

// reload nebula even if we couldn't save the vpn profile
onUpdate(Site(incoming: newSite!))
}

if (credentials.invalid) {
Expand Down

0 comments on commit e58078f

Please sign in to comment.