Format swift code

This commit is contained in:
Caleb Jasik 2025-02-20 15:20:17 -06:00
parent f1306d82d4
commit e87b7809b4
No known key found for this signature in database
9 changed files with 1569 additions and 1491 deletions

View file

@ -3,67 +3,68 @@ import Foundation
let groupName = "group.net.defined.mobileNebula" let groupName = "group.net.defined.mobileNebula"
class KeyChain { class KeyChain {
class func save(key: String, data: Data, managed: Bool) -> Bool { class func save(key: String, data: Data, managed: Bool) -> Bool {
var query: [String: Any] = [ var query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String, kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String : key, kSecAttrAccount as String: key,
kSecValueData as String : data, kSecValueData as String: data,
kSecAttrAccessGroup as String: groupName, kSecAttrAccessGroup as String: groupName,
] ]
if (managed) {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
// Attempt to delete an existing key to allow for an overwrite if managed {
_ = self.delete(key: key) query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(query as CFDictionary, nil) == 0
} }
class func load(key: String) -> Data? { // Attempt to delete an existing key to allow for an overwrite
let query: [String: Any] = [ _ = self.delete(key: key)
kSecClass as String : kSecClassGenericPassword, return SecItemAdd(query as CFDictionary, nil) == 0
kSecAttrAccount as String : key, }
kSecReturnData as String : kCFBooleanTrue!,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName,
]
var dataTypeRef: AnyObject? = nil class func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName,
]
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) var dataTypeRef: AnyObject? = nil
if status == noErr { let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
return dataTypeRef as! Data?
} else { if status == noErr {
return nil return dataTypeRef as! Data?
} } else {
return nil
} }
}
class func delete(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String,
kSecAttrAccount as String : key,
kSecAttrAccessGroup as String: groupName,
]
return SecItemDelete(query as CFDictionary) == 0 class func delete(key: String) -> Bool {
} let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: groupName,
]
return SecItemDelete(query as CFDictionary) == 0
}
} }
extension Data { extension Data {
init<T>(from value: T) { init<T>(from value: T) {
var value = value var value = value
var data = Data() var data = Data()
withUnsafePointer(to: &value, { (ptr: UnsafePointer<T>) -> Void in withUnsafePointer(
data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1)) to: &value,
}) { (ptr: UnsafePointer<T>) -> Void in
self.init(data) data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1))
} })
self.init(data)
}
func to<T>(type: T.Type) -> T { func to<T>(type: T.Type) -> T {
return self.withUnsafeBytes { $0.load(as: T.self) } return self.withUnsafeBytes { $0.load(as: T.self) }
} }
} }

View file

@ -1,309 +1,323 @@
import NetworkExtension
import MobileNebula import MobileNebula
import os.log import NetworkExtension
import SwiftyJSON import SwiftyJSON
import os.log
enum VPNStartError: Error { enum VPNStartError: Error {
case noManagers case noManagers
case couldNotFindManager case couldNotFindManager
case noTunFileDescriptor case noTunFileDescriptor
case noProviderConfig case noProviderConfig
} }
enum AppMessageError: Error { enum AppMessageError: Error {
case unknownIPCType(command: String) case unknownIPCType(command: String)
} }
extension AppMessageError: LocalizedError { extension AppMessageError: LocalizedError {
public var description: String? { public var description: String? {
switch self { switch self {
case .unknownIPCType(let command): case .unknownIPCType(let command):
return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "") return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "")
}
} }
}
} }
class PacketTunnelProvider: NEPacketTunnelProvider { class PacketTunnelProvider: NEPacketTunnelProvider {
private var networkMonitor: NWPathMonitor? private var networkMonitor: NWPathMonitor?
private var site: Site?
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater()
private var didSleep = false
private var cachedRouteDescription: String?
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 {
// 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
try await start()
}
private func start() async throws {
var manager: NETunnelProviderManager?
var config: Data
var key: String
do {
// Cannot use NETunnelProviderManager.loadAllFromPreferences() in earlier versions of iOS
// TODO: Remove else once we drop support for iOS 16
if ProcessInfo().isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)) {
manager = try await self.findManager()
guard let foundManager = manager else {
throw VPNStartError.couldNotFindManager
}
self.site = try Site(manager: foundManager)
} else {
// This does not save the manager with the site, which means we cannot update the
// vpn profile name when updates happen (rare).
self.site = try Site(proto: self.protocolConfiguration as! NETunnelProviderProtocol)
}
config = try self.site!.getConfig()
} catch {
//TODO: need a way to notify the app
self.log.error("Failed to render config from vpn object")
throw error
}
let _site = self.site! private var site: Site?
key = try _site.getKey() private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula?
guard let fileDescriptor = self.tunnelFileDescriptor else { private var dnUpdater = DNUpdater()
throw VPNStartError.noTunFileDescriptor private var didSleep = false
} private var cachedRouteDescription: String?
let tunFD = Int(fileDescriptor)
// This is set to 127.0.0.1 because it has to be something.. override func startTunnel(options: [String: NSObject]? = nil) async throws {
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") // 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 {
// startTunnel must complete before IPC will work
return
}
// Make sure our ip is routed to the tun device // 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
var err: NSError? // success/fail by the presence of an error or nil
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err) try await start()
if (err != nil) { }
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 private func start() async throws {
try _site.unsafeRoutes.forEach { unsafeRoute in var manager: NETunnelProviderManager?
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err) var config: Data
if (err != nil) { var key: String
throw err!
}
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
}
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes do {
tunnelNetworkSettings.mtu = _site.mtu as NSNumber // Cannot use NETunnelProviderManager.loadAllFromPreferences() in earlier versions of iOS
// TODO: Remove else once we drop support for iOS 16
if ProcessInfo().isOperatingSystemAtLeast(
OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0))
{
manager = try await self.findManager()
guard let foundManager = manager else {
throw VPNStartError.couldNotFindManager
}
self.site = try Site(manager: foundManager)
} else {
// This does not save the manager with the site, which means we cannot update the
// vpn profile name when updates happen (rare).
self.site = try Site(proto: self.protocolConfiguration as! NETunnelProviderProtocol)
}
config = try self.site!.getConfig()
} catch {
//TODO: need a way to notify the app
self.log.error("Failed to render config from vpn object")
throw error
}
try await self.setTunnelNetworkSettings(tunnelNetworkSettings) let _site = self.site!
var nebulaErr: NSError? key = try _site.getKey()
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &nebulaErr)
self.startNetworkMonitor()
if nebulaErr != nil { guard let fileDescriptor = self.tunnelFileDescriptor else {
self.log.error("We had an error starting up: \(nebulaErr, privacy: .public)") throw VPNStartError.noTunFileDescriptor
throw nebulaErr! }
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")
// Make sure our ip is routed to the tun device
var err: NSError?
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
if err != nil {
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
try _site.unsafeRoutes.forEach { unsafeRoute in
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
if err != nil {
throw err!
}
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
}
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber
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 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) {
do {
self.site = newSite
try self.nebula?.reload(
String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey())
} catch {
log.error(
"Got an error while updating nebula \(error.localizedDescription, privacy: .public)")
}
}
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately
// override func sleep(completionHandler: @escaping () -> Void) {
// nebula!.sleep()
// 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
networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
private func stopNetworkMonitor() {
self.networkMonitor?.cancel()
networkMonitor = nil
}
override func stopTunnel(
with reason: NEProviderStopReason, completionHandler: @escaping () -> Void
) {
nebula?.stop()
stopNetworkMonitor()
completionHandler()
}
private func pathUpdate(path: Network.NWPath) {
let routeDescription = collectAddresses(endpoints: path.gateways)
if routeDescription != cachedRouteDescription {
// Don't bother to rebind if we don't have any gateways
if routeDescription != "" {
nebula?.rebind(
"network change to: \(routeDescription); from: \(cachedRouteDescription ?? "none")")
}
cachedRouteDescription = routeDescription
}
}
private func collectAddresses(endpoints: [Network.NWEndpoint]) -> String {
var str: [String] = []
endpoints.forEach { endpoint in
switch endpoint {
case let .hostPort(.ipv6(host), port):
str.append("[\(host)]:\(port)")
case let .hostPort(.ipv4(host), port):
str.append("\(host):\(port)")
default:
return
}
}
return str.sorted().joined(separator: ", ")
}
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 nil
}
var error: Error?
var data: JSON?
// start command has special treatment due to needing to call two completers
if call.command == "start" {
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(
self.nebula!.start() IPCResponse.init(type: .error, message: JSON(error.localizedDescription)))
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate) }
} }
private func handleDNUpdate(newSite: Site) { if nebula == nil {
do { // Respond with an empty success message in the event a command comes in before we've truly started
self.site = newSite log.warning("Received command but do not have a nebula instance")
try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey()) return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}
} catch {
log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)") //TODO: try catch over all this
switch call.command {
case "listHostmap": (data, error) = listHostmap(pending: false)
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
case "setRemoteForTunnel": (data, error) = setRemoteForTunnel(args: call.arguments!)
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
default:
error = AppMessageError.unknownIPCType(command: call.command)
}
if error != nil {
return try? JSONEncoder().encode(
IPCResponse.init(
type: .error, message: JSON(error?.localizedDescription ?? "Unknown error")))
} else {
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data))
}
}
private func listHostmap(pending: Bool) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.listHostmap(pending, error: &err)
return (JSON(res), err)
}
private func getHostInfo(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.getHostInfo(
byVpnIp: args["vpnIp"].string, pending: args["pending"].boolValue, error: &err)
return (JSON(res), err)
}
private func setRemoteForTunnel(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.setRemoteForTunnel(
args["vpnIp"].string, addr: args["addr"].string, error: &err)
return (JSON(res), err)
}
private func closeTunnel(args: JSON) -> (JSON?, Error?) {
let res = nebula!.closeTunnel(args["vpnIp"].string)
return (JSON(res), nil)
}
private var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
_ = strcpy($0, "com.apple.net.utun_control")
}
}
for fd: Int32 in 0...1024 {
var addr = sockaddr_ctl()
var ret: Int32 = -1
var len = socklen_t(MemoryLayout.size(ofValue: addr))
withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
ret = getpeername(fd, $0, &len)
} }
} }
if ret != 0 || addr.sc_family != AF_SYSTEM {
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately continue
// override func sleep(completionHandler: @escaping () -> Void) { }
// nebula!.sleep() if ctlInfo.ctl_id == 0 {
// completionHandler() ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
// } if ret != 0 {
continue
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 }
if addr.sc_id == ctlInfo.ctl_id {
// Load vpn configs from system, and find the manager matching the one being started return fd
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
networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
private func stopNetworkMonitor() {
self.networkMonitor?.cancel()
networkMonitor = nil
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
nebula?.stop()
stopNetworkMonitor()
completionHandler()
}
private func pathUpdate(path: Network.NWPath) {
let routeDescription = collectAddresses(endpoints: path.gateways)
if routeDescription != cachedRouteDescription {
// Don't bother to rebind if we don't have any gateways
if routeDescription != "" {
nebula?.rebind("network change to: \(routeDescription); from: \(cachedRouteDescription ?? "none")")
}
cachedRouteDescription = routeDescription
}
}
private func collectAddresses(endpoints: [Network.NWEndpoint]) -> String {
var str: [String] = []
endpoints.forEach{ endpoint in
switch endpoint {
case let .hostPort(.ipv6(host), port):
str.append("[\(host)]:\(port)")
case let .hostPort(.ipv4(host), port):
str.append("\(host):\(port)")
default:
return
}
}
return str.sorted().joined(separator: ", ")
}
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 nil
}
var error: Error?
var data: JSON?
// start command has special treatment due to needing to call two completers
if call.command == "start" {
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)))
}
}
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 try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}
//TODO: try catch over all this
switch call.command {
case "listHostmap": (data, error) = listHostmap(pending: false)
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
case "setRemoteForTunnel": (data, error) = setRemoteForTunnel(args: call.arguments!)
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
default:
error = AppMessageError.unknownIPCType(command: call.command)
}
if (error != nil) {
return try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error?.localizedDescription ?? "Unknown error")))
} else {
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data))
}
}
private func listHostmap(pending: Bool) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.listHostmap(pending, error: &err)
return (JSON(res), err)
}
private func getHostInfo(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.getHostInfo(byVpnIp: args["vpnIp"].string, pending: args["pending"].boolValue, error: &err)
return (JSON(res), err)
}
private func setRemoteForTunnel(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.setRemoteForTunnel(args["vpnIp"].string, addr: args["addr"].string, error: &err)
return (JSON(res), err)
}
private func closeTunnel(args: JSON) -> (JSON?, Error?) {
let res = nebula!.closeTunnel(args["vpnIp"].string)
return (JSON(res), nil)
}
private var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
_ = strcpy($0, "com.apple.net.utun_control")
}
}
for fd: Int32 in 0...1024 {
var addr = sockaddr_ctl()
var ret: Int32 = -1
var len = socklen_t(MemoryLayout.size(ofValue: addr))
withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
ret = getpeername(fd, $0, &len)
}
}
if ret != 0 || addr.sc_family != AF_SYSTEM {
continue
}
if ctlInfo.ctl_id == 0 {
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
if ret != 0 {
continue
}
}
if addr.sc_id == ctlInfo.ctl_id {
return fd
}
}
return nil
} }
return nil
}
} }

View file

@ -1,543 +1,550 @@
import NetworkExtension
import MobileNebula import MobileNebula
import NetworkExtension
import SwiftyJSON import SwiftyJSON
import os.log import os.log
let log = Logger(subsystem: "net.defined.mobileNebula", category: "Site") let log = Logger(subsystem: "net.defined.mobileNebula", category: "Site")
enum SiteError: Error { enum SiteError: Error {
case nonConforming(site: [String : Any]?) case nonConforming(site: [String: Any]?)
case noCertificate case noCertificate
case keyLoad case keyLoad
case keySave case keySave
case unmanagedGetCredentials case unmanagedGetCredentials
case dnCredentialLoad case dnCredentialLoad
case dnCredentialSave case dnCredentialSave
// Throw in all other cases // Throw in all other cases
case unexpected(code: Int) case unexpected(code: Int)
} }
extension SiteError: CustomStringConvertible { extension SiteError: CustomStringConvertible {
public var description: String { public var description: String {
switch self { switch self {
case .nonConforming(let site): case .nonConforming(let site):
return String("Non-conforming site \(String(describing: site))") return String("Non-conforming site \(String(describing: site))")
case .noCertificate: case .noCertificate:
return "No certificate found" return "No certificate found"
case .keyLoad: case .keyLoad:
return "failed to get key from keychain" return "failed to get key from keychain"
case .keySave: case .keySave:
return "failed to store key material in keychain" return "failed to store key material in keychain"
case .unmanagedGetCredentials: case .unmanagedGetCredentials:
return "Cannot get dn credentials for unmanaged site" return "Cannot get dn credentials for unmanaged site"
case .dnCredentialLoad: case .dnCredentialLoad:
return "failed to find dn credentials in keychain" return "failed to find dn credentials in keychain"
case .dnCredentialSave: case .dnCredentialSave:
return "failed to store dn credentials in keychain" return "failed to store dn credentials in keychain"
case .unexpected(_): case .unexpected(_):
return "An unexpected error occurred." return "An unexpected error occurred."
}
} }
}
} }
enum IPCResponseType: String, Codable { enum IPCResponseType: String, Codable {
case error = "error" case error = "error"
case success = "success" case success = "success"
} }
class IPCResponse: Codable { class IPCResponse: Codable {
var type: IPCResponseType var type: IPCResponseType
//TODO: change message to data? //TODO: change message to data?
var message: JSON? var message: JSON?
init(type: IPCResponseType, message: JSON?) { init(type: IPCResponseType, message: JSON?) {
self.type = type self.type = type
self.message = message self.message = message
} }
} }
class IPCRequest: Codable { class IPCRequest: Codable {
var command: String var command: String
var arguments: JSON? var arguments: JSON?
init(command: String, arguments: JSON?) { init(command: String, arguments: JSON?) {
self.command = command self.command = command
self.arguments = arguments self.arguments = arguments
} }
init(command: String) { init(command: String) {
self.command = command self.command = command
} }
} }
struct CertificateInfo: Codable { struct CertificateInfo: Codable {
var cert: Certificate var cert: Certificate
var rawCert: String var rawCert: String
var validity: CertificateValidity var validity: CertificateValidity
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case cert = "Cert" case cert = "Cert"
case rawCert = "RawCert" case rawCert = "RawCert"
case validity = "Validity" case validity = "Validity"
} }
} }
struct Certificate: Codable { struct Certificate: Codable {
var fingerprint: String var fingerprint: String
var signature: String var signature: String
var details: CertificateDetails var details: CertificateDetails
/// An empty initializer to make error reporting easier /// An empty initializer to make error reporting easier
init() { init() {
fingerprint = "" fingerprint = ""
signature = "" signature = ""
details = CertificateDetails() details = CertificateDetails()
} }
} }
struct CertificateDetails: Codable { struct CertificateDetails: Codable {
var name: String var name: String
var notBefore: String var notBefore: String
var notAfter: String var notAfter: String
var publicKey: String var publicKey: String
var groups: [String] var groups: [String]
var ips: [String] var ips: [String]
var subnets: [String] var subnets: [String]
var isCa: Bool var isCa: Bool
var issuer: String var issuer: String
/// An empty initializer to make error reporting easier /// An empty initializer to make error reporting easier
init() { init() {
name = "" name = ""
notBefore = "" notBefore = ""
notAfter = "" notAfter = ""
publicKey = "" publicKey = ""
groups = [] groups = []
ips = ["ERROR"] ips = ["ERROR"]
subnets = [] subnets = []
isCa = false isCa = false
issuer = "" issuer = ""
} }
} }
struct CertificateValidity: Codable { struct CertificateValidity: Codable {
var valid: Bool var valid: Bool
var reason: String var reason: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case valid = "Valid" case valid = "Valid"
case reason = "Reason" case reason = "Reason"
} }
} }
let statusMap: Dictionary<NEVPNStatus, Bool> = [ let statusMap: [NEVPNStatus: Bool] = [
NEVPNStatus.invalid: false, NEVPNStatus.invalid: false,
NEVPNStatus.disconnected: false, NEVPNStatus.disconnected: false,
NEVPNStatus.connecting: false, NEVPNStatus.connecting: false,
NEVPNStatus.connected: true, NEVPNStatus.connected: true,
NEVPNStatus.reasserting: true, NEVPNStatus.reasserting: true,
NEVPNStatus.disconnecting: true, NEVPNStatus.disconnecting: true,
] ]
let statusString: Dictionary<NEVPNStatus, String> = [ let statusString: [NEVPNStatus: String] = [
NEVPNStatus.invalid: "Invalid configuration", NEVPNStatus.invalid: "Invalid configuration",
NEVPNStatus.disconnected: "Disconnected", NEVPNStatus.disconnected: "Disconnected",
NEVPNStatus.connecting: "Connecting...", NEVPNStatus.connecting: "Connecting...",
NEVPNStatus.connected: "Connected", NEVPNStatus.connected: "Connected",
NEVPNStatus.reasserting: "Reasserting...", NEVPNStatus.reasserting: "Reasserting...",
NEVPNStatus.disconnecting: "Disconnecting...", NEVPNStatus.disconnecting: "Disconnecting...",
] ]
// Represents a site that was pulled out of the system configuration // Represents a site that was pulled out of the system configuration
class Site: Codable { class Site: Codable {
// Stored in manager // Stored in manager
var name: String var name: String
var id: String var id: String
// Stored in proto // Stored in proto
var staticHostmap: Dictionary<String, StaticHosts> var staticHostmap: [String: StaticHosts]
var unsafeRoutes: [UnsafeRoute] var unsafeRoutes: [UnsafeRoute]
var cert: CertificateInfo? var cert: CertificateInfo?
var ca: [CertificateInfo] var ca: [CertificateInfo]
var lhDuration: Int var lhDuration: Int
var port: Int var port: Int
var mtu: Int var mtu: Int
var cipher: String var cipher: String
var sortKey: Int var sortKey: Int
var logVerbosity: String var logVerbosity: String
var connected: Bool? //TODO: active is a better name var connected: Bool? //TODO: active is a better name
var status: String? var status: String?
var logFile: String? var logFile: String?
var managed: Bool var managed: Bool
// The following fields are present if managed = true // The following fields are present if managed = true
var lastManagedUpdate: String? var lastManagedUpdate: String?
var rawConfig: String? var rawConfig: String?
/// If true then this site needs to be migrated to the filesystem. Should be handled by the initiator of the site /// If true then this site needs to be migrated to the filesystem. Should be handled by the initiator of the site
var needsToMigrateToFS: Bool = false var needsToMigrateToFS: Bool = false
// A list of error encountered when trying to rehydrate a site from config // A list of error encountered when trying to rehydrate a site from config
var errors: [String] var errors: [String]
var manager: NETunnelProviderManager? var manager: NETunnelProviderManager?
var incomingSite: IncomingSite? var incomingSite: IncomingSite?
/// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile /// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile
convenience init(manager: NETunnelProviderManager) throws { convenience init(manager: NETunnelProviderManager) throws {
//TODO: Throw an error and have Sites delete the site, notify the user instead of using ! //TODO: Throw an error and have Sites delete the site, notify the user instead of using !
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
try self.init(proto: proto) try self.init(proto: proto)
self.manager = manager self.manager = manager
self.connected = statusMap[manager.connection.status] self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status] self.status = statusString[manager.connection.status]
}
convenience init(proto: NETunnelProviderProtocol) throws {
let dict = proto.providerConfiguration
if dict?["config"] != nil {
let config = dict?["config"] as? Data ?? Data()
let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config)
self.init(incoming: incoming)
self.needsToMigrateToFS = true
return
} }
convenience init(proto: NETunnelProviderProtocol) throws { let id = dict?["id"] as? String ?? nil
let dict = proto.providerConfiguration if id == nil {
throw SiteError.nonConforming(site: dict)
}
if dict?["config"] != nil { try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false))
let config = dict?["config"] as? Data ?? Data() }
let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config) /// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager
self.init(incoming: incoming) convenience init(path: URL) throws {
self.needsToMigrateToFS = true let config = try Data(contentsOf: path)
return let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config)
self.init(incoming: incoming)
}
init(incoming: IncomingSite) {
var err: NSError?
incomingSite = incoming
errors = []
name = incoming.name
id = incoming.id
staticHostmap = incoming.staticHostmap
unsafeRoutes = incoming.unsafeRoutes ?? []
lhDuration = incoming.lhDuration
port = incoming.port
cipher = incoming.cipher
sortKey = incoming.sortKey ?? 0
logVerbosity = incoming.logVerbosity ?? "info"
mtu = incoming.mtu ?? 1300
managed = incoming.managed ?? false
lastManagedUpdate = incoming.lastManagedUpdate
rawConfig = incoming.rawConfig
do {
let rawCert = incoming.cert
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
if err != nil {
throw err!
}
var certs: [CertificateInfo]
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
if certs.count == 0 {
throw SiteError.noCertificate
}
cert = certs[0]
if !cert!.validity.valid {
errors.append("Certificate is invalid: \(cert!.validity.reason)")
}
} catch {
errors.append("Error while loading certificate: \(error.localizedDescription)")
}
do {
let rawCa = incoming.ca
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
if err != nil {
throw err!
}
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
var hasErrors = false
ca.forEach { cert in
if !cert.validity.valid {
hasErrors = true
} }
}
let id = dict?["id"] as? String ?? nil if hasErrors && !managed {
if id == nil { errors.append("There are issues with 1 or more ca certificates")
throw SiteError.nonConforming(site: dict) }
}
try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false)) } catch {
ca = []
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
} }
/// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager do {
convenience init(path: URL) throws { logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
let config = try Data(contentsOf: path) } catch {
let decoder = JSONDecoder() logFile = nil
let incoming = try decoder.decode(IncomingSite.self, from: config) errors.append("Unable to create the site directory: \(error.localizedDescription)")
self.init(incoming: incoming)
} }
init(incoming: IncomingSite) { if managed && (try? getDNCredentials())?.invalid != false {
errors.append("Unable to fetch managed updates - please re-enroll the device")
}
if errors.isEmpty {
do {
let encoder = JSONEncoder()
let rawConfig = try encoder.encode(incoming)
let key = try getKey()
let strConfig = String(data: rawConfig, encoding: .utf8)
var err: NSError? var err: NSError?
incomingSite = incoming MobileNebulaTestConfig(strConfig, key, &err)
errors = [] if err != nil {
name = incoming.name throw err!
id = incoming.id
staticHostmap = incoming.staticHostmap
unsafeRoutes = incoming.unsafeRoutes ?? []
lhDuration = incoming.lhDuration
port = incoming.port
cipher = incoming.cipher
sortKey = incoming.sortKey ?? 0
logVerbosity = incoming.logVerbosity ?? "info"
mtu = incoming.mtu ?? 1300
managed = incoming.managed ?? false
lastManagedUpdate = incoming.lastManagedUpdate
rawConfig = incoming.rawConfig
do {
let rawCert = incoming.cert
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
if (err != nil) {
throw err!
}
var certs: [CertificateInfo]
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
if (certs.count == 0) {
throw SiteError.noCertificate
}
cert = certs[0]
if (!cert!.validity.valid) {
errors.append("Certificate is invalid: \(cert!.validity.reason)")
}
} catch {
errors.append("Error while loading certificate: \(error.localizedDescription)")
} }
} catch {
errors.append("Config test error: \(error.localizedDescription)")
}
}
}
do { // Gets the private key from the keystore, we don't always need it in memory
let rawCa = incoming.ca func getKey() throws -> String {
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err) guard let keyData = KeyChain.load(key: "\(id).key") else {
if (err != nil) { throw SiteError.keyLoad
throw err!
}
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
var hasErrors = false
ca.forEach { cert in
if (!cert.validity.valid) {
hasErrors = true
}
}
if (hasErrors && !managed) {
errors.append("There are issues with 1 or more ca certificates")
}
} catch {
ca = []
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
}
do {
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
} catch {
logFile = nil
errors.append("Unable to create the site directory: \(error.localizedDescription)")
}
if (managed && (try? getDNCredentials())?.invalid != false) {
errors.append("Unable to fetch managed updates - please re-enroll the device")
}
if (errors.isEmpty) {
do {
let encoder = JSONEncoder()
let rawConfig = try encoder.encode(incoming)
let key = try getKey()
let strConfig = String(data: rawConfig, encoding: .utf8)
var err: NSError?
MobileNebulaTestConfig(strConfig, key, &err)
if (err != nil) {
throw err!
}
} catch {
errors.append("Config test error: \(error.localizedDescription)")
}
}
} }
// Gets the private key from the keystore, we don't always need it in memory //TODO: make sure this is valid on return!
func getKey() throws -> String { return String(decoding: keyData, as: UTF8.self)
guard let keyData = KeyChain.load(key: "\(id).key") else { }
throw SiteError.keyLoad
}
//TODO: make sure this is valid on return! func getDNCredentials() throws -> DNCredentials {
return String(decoding: keyData, as: UTF8.self) if !managed {
throw SiteError.unmanagedGetCredentials
} }
func getDNCredentials() throws -> DNCredentials { let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
if (!managed) { if rawDNCredentials == nil {
throw SiteError.unmanagedGetCredentials throw SiteError.dnCredentialLoad
}
let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
if rawDNCredentials == nil {
throw SiteError.dnCredentialLoad
}
let decoder = JSONDecoder()
return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
} }
func invalidateDNCredentials() throws { let decoder = JSONDecoder()
let creds = try getDNCredentials() return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
creds.invalid = true }
if (!(try creds.save(siteID: self.id))) { func invalidateDNCredentials() throws {
throw SiteError.dnCredentialLoad let creds = try getDNCredentials()
} creds.invalid = true
if !(try creds.save(siteID: self.id)) {
throw SiteError.dnCredentialLoad
} }
}
func validateDNCredentials() throws { func validateDNCredentials() throws {
let creds = try getDNCredentials() let creds = try getDNCredentials()
creds.invalid = false creds.invalid = false
if (!(try creds.save(siteID: self.id))) { if !(try creds.save(siteID: self.id)) {
throw SiteError.dnCredentialSave throw SiteError.dnCredentialSave
}
} }
}
func getConfig() throws -> Data { func getConfig() throws -> Data {
return try self.incomingSite!.getConfig() return try self.incomingSite!.getConfig()
} }
// Limits what we export to the UI // Limits what we export to the UI
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
case id case id
case staticHostmap case staticHostmap
case cert case cert
case ca case ca
case lhDuration case lhDuration
case port case port
case cipher case cipher
case sortKey case sortKey
case connected case connected
case status case status
case logFile case logFile
case unsafeRoutes case unsafeRoutes
case logVerbosity case logVerbosity
case errors case errors
case mtu case mtu
case managed case managed
case lastManagedUpdate case lastManagedUpdate
case rawConfig case rawConfig
} }
} }
class StaticHosts: Codable { class StaticHosts: Codable {
var lighthouse: Bool var lighthouse: Bool
var destinations: [String] var destinations: [String]
} }
class UnsafeRoute: Codable { class UnsafeRoute: Codable {
var route: String var route: String
var via: String var via: String
var mtu: Int? var mtu: Int?
} }
class DNCredentials: Codable { class DNCredentials: Codable {
var hostID: String var hostID: String
var privateKey: String var privateKey: String
var counter: Int var counter: Int
var trustedKeys: String var trustedKeys: String
var invalid: Bool { var invalid: Bool {
get { return _invalid ?? false } get { return _invalid ?? false }
set { _invalid = newValue } set { _invalid = newValue }
} }
private var _invalid: Bool? private var _invalid: Bool?
func save(siteID: String) throws -> Bool { func save(siteID: String) throws -> Bool {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let rawDNCredentials = try encoder.encode(self) let rawDNCredentials = try encoder.encode(self)
return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true) return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true)
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case hostID case hostID
case privateKey case privateKey
case counter case counter
case trustedKeys case trustedKeys
case _invalid = "invalid" case _invalid = "invalid"
} }
} }
// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site // This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
struct IncomingSite: Codable { struct IncomingSite: Codable {
var name: String var name: String
var id: String var id: String
var staticHostmap: Dictionary<String, StaticHosts> var staticHostmap: [String: StaticHosts]
var unsafeRoutes: [UnsafeRoute]? var unsafeRoutes: [UnsafeRoute]?
var cert: String? var cert: String?
var ca: String? var ca: String?
var lhDuration: Int var lhDuration: Int
var port: Int var port: Int
var mtu: Int? var mtu: Int?
var cipher: String var cipher: String
var sortKey: Int? var sortKey: Int?
var logVerbosity: String? var logVerbosity: String?
var key: String? var key: String?
var managed: Bool? var managed: Bool?
// The following fields are present if managed = true // The following fields are present if managed = true
var dnCredentials: DNCredentials? var dnCredentials: DNCredentials?
var lastManagedUpdate: String? var lastManagedUpdate: String?
var rawConfig: String? var rawConfig: String?
func getConfig() throws -> Data { func getConfig() throws -> Data {
let encoder = JSONEncoder() let encoder = JSONEncoder()
var config = self var config = self
config.key = nil config.key = nil
config.dnCredentials = nil config.dnCredentials = nil
return try encoder.encode(config) return try encoder.encode(config)
}
func save(
manager: NETunnelProviderManager?, saveToManager: Bool = true,
callback: @escaping (Error?) -> Void
) {
let configPath: URL
do {
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
} catch {
callback(error)
return
} }
func save(manager: NETunnelProviderManager?, saveToManager: Bool = true, callback: @escaping (Error?) -> ()) { log.notice("Saving to \(configPath, privacy: .public)")
let configPath: URL do {
if self.key != nil {
do { let data = self.key!.data(using: .utf8)
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true) if !KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false) {
return callback(SiteError.keySave)
} catch {
callback(error)
return
} }
}
log.notice("Saving to \(configPath, privacy: .public)") do {
do { if (try self.dnCredentials?.save(siteID: self.id)) == false {
if (self.key != nil) { return callback(SiteError.dnCredentialSave)
let data = self.key!.data(using: .utf8)
if (!KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false)) {
return callback(SiteError.keySave)
}
}
do {
if ((try self.dnCredentials?.save(siteID: self.id)) == false) {
return callback(SiteError.dnCredentialSave)
}
} catch {
return callback(error)
}
try self.getConfig().write(to: configPath)
} catch {
return callback(error)
} }
} catch {
return callback(error)
}
try self.getConfig().write(to: configPath)
#if targetEnvironment(simulator) } catch {
// We are on a simulator and there is no NEVPNManager for us to interact with return callback(error)
}
#if targetEnvironment(simulator)
// We are on a simulator and there is no NEVPNManager for us to interact with
callback(nil)
#else
if saveToManager {
self.saveToManager(manager: manager, callback: callback)
} else {
callback(nil) callback(nil)
#else }
if saveToManager { #endif
self.saveToManager(manager: manager, callback: callback) }
} else {
callback(nil)
}
#endif
}
private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { private func saveToManager(
if (manager != nil) { manager: NETunnelProviderManager?, callback: @escaping (Error?) -> Void
// We need to refresh our settings to properly update config ) {
manager?.loadFromPreferences { error in if manager != nil {
if (error != nil) { // We need to refresh our settings to properly update config
return callback(error) manager?.loadFromPreferences { error in
} if error != nil {
return callback(error)
return self.finishSaveToManager(manager: manager!, callback: callback)
}
return
} }
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback) return self.finishSaveToManager(manager: manager!, callback: callback)
}
return
} }
private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) { return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
// 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"
// Finish up the manager, this is what stores everything at the system level private func finishSaveToManager(
manager.protocolConfiguration = proto manager: NETunnelProviderManager, callback: @escaping (Error?) -> Void
//TODO: cert name? manager.protocolConfiguration?.username ) {
// 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"
//TODO: This is what is shown on the vpn page. We should add more identifying details in // Finish up the manager, this is what stores everything at the system level
manager.localizedDescription = self.name manager.protocolConfiguration = proto
manager.isEnabled = true //TODO: cert name? manager.protocolConfiguration?.username
manager.saveToPreferences{ error in //TODO: This is what is shown on the vpn page. We should add more identifying details in
return callback(error) manager.localizedDescription = self.name
} manager.isEnabled = true
manager.saveToPreferences { error in
return callback(error)
} }
}
} }

View file

@ -1,140 +1,146 @@
import NetworkExtension import NetworkExtension
class SiteList { class SiteList {
private var sites = [String: Site]() private var sites = [String: Site]()
/// Gets the root directory that can be used to share files between the UI and VPN process. Does ensure the directory exists /// Gets the root directory that can be used to share files between the UI and VPN process. Does ensure the directory exists
static func getRootDir() throws -> URL { static func getRootDir() throws -> URL {
let fileManager = FileManager.default let fileManager = FileManager.default
let rootDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")! let rootDir = fileManager.containerURL(
forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")!
if (!fileManager.fileExists(atPath: rootDir.absoluteString)) {
try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true) if !fileManager.fileExists(atPath: rootDir.absoluteString) {
try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true)
}
return rootDir
}
/// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists
static func getSitesDir() throws -> URL {
let fileManager = FileManager.default
let sitesDir = try getRootDir().appendingPathComponent("sites", isDirectory: true)
if !fileManager.fileExists(atPath: sitesDir.absoluteString) {
try fileManager.createDirectory(at: sitesDir, withIntermediateDirectories: true)
}
return sitesDir
}
/// Gets the directory where a single site would live, $rootDir/sites/$siteID
static func getSiteDir(id: String, create: Bool = false) throws -> URL {
let fileManager = FileManager.default
let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true)
if create && !fileManager.fileExists(atPath: siteDir.absoluteString) {
try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true)
}
return siteDir
}
/// Gets the file that represents the site configuration, $rootDir/sites/$siteID/config.json
static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL {
return try getSiteDir(id: id, create: createDir).appendingPathComponent(
"config", isDirectory: false
).appendingPathExtension("json")
}
/// Gets the file that represents the site log output, $rootDir/sites/$siteID/log
static func getSiteLogFile(id: String, createDir: Bool) throws -> URL {
return try getSiteDir(id: id, create: createDir).appendingPathComponent(
"logs", isDirectory: false)
}
init(completion: @escaping ([String: Site]?, Error?) -> Void) {
#if targetEnvironment(simulator)
SiteList.loadAllFromFS { sites, err in
if sites != nil {
self.sites = sites!
} }
completion(sites, err)
return rootDir }
} #else
SiteList.loadAllFromNETPM { sites, err in
/// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists if sites != nil {
static func getSitesDir() throws -> URL { self.sites = sites!
let fileManager = FileManager.default
let sitesDir = try getRootDir().appendingPathComponent("sites", isDirectory: true)
if (!fileManager.fileExists(atPath: sitesDir.absoluteString)) {
try fileManager.createDirectory(at: sitesDir, withIntermediateDirectories: true)
} }
return sitesDir completion(sites, err)
}
#endif
}
private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> Void) {
let fileManager = FileManager.default
var siteDirs: [URL]
var sites = [String: Site]()
do {
siteDirs = try fileManager.contentsOfDirectory(
at: getSitesDir(), includingPropertiesForKeys: nil)
} catch {
completion(nil, error)
return
} }
/// Gets the directory where a single site would live, $rootDir/sites/$siteID siteDirs.forEach { path in
static func getSiteDir(id: String, create: Bool = false) throws -> URL { do {
let fileManager = FileManager.default let site = try Site(
let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true) path: path.appendingPathComponent("config").appendingPathExtension("json"))
if (create && !fileManager.fileExists(atPath: siteDir.absoluteString)) { sites[site.id] = site
try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true)
} } catch {
return siteDir print(error)
try? fileManager.removeItem(at: path)
print("Deleted non conforming site \(path)")
}
} }
/// Gets the file that represents the site configuration, $rootDir/sites/$siteID/config.json completion(sites, nil)
static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL { }
return try getSiteDir(id: id, create: createDir).appendingPathComponent("config", isDirectory: false).appendingPathExtension("json")
} private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> Void) {
var sites = [String: Site]()
/// Gets the file that represents the site log output, $rootDir/sites/$siteID/log
static func getSiteLogFile(id: String, createDir: Bool) throws -> URL { // dispatchGroup is used to ensure we have migrated all sites before returning them
return try getSiteDir(id: id, create: createDir).appendingPathComponent("logs", isDirectory: false) // If there are no sites to migrate, there are never any entrants
} let dispatchGroup = DispatchGroup()
init(completion: @escaping ([String: Site]?, Error?) -> ()) { NETunnelProviderManager.loadAllFromPreferences { newManagers, err in
#if targetEnvironment(simulator) if err != nil {
SiteList.loadAllFromFS { sites, err in return completion(nil, err)
if sites != nil { }
self.sites = sites!
} newManagers?.forEach { manager in
completion(sites, err)
}
#else
SiteList.loadAllFromNETPM { sites, err in
if sites != nil {
self.sites = sites!
}
completion(sites, err)
}
#endif
}
private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> ()) {
let fileManager = FileManager.default
var siteDirs: [URL]
var sites = [String: Site]()
do { do {
siteDirs = try fileManager.contentsOfDirectory(at: getSitesDir(), includingPropertiesForKeys: nil) let site = try Site(manager: manager)
if site.needsToMigrateToFS {
dispatchGroup.enter()
site.incomingSite?.save(manager: manager) { error in
if error != nil {
print("Error while migrating site to fs: \(error!.localizedDescription)")
}
print("Migrated site to fs: \(site.name)")
site.needsToMigrateToFS = false
dispatchGroup.leave()
}
}
sites[site.id] = site
} catch { } catch {
completion(nil, error) //TODO: notify the user about this
return print("Deleted non conforming site \(manager) \(error)")
manager.removeFromPreferences()
//TODO: delete from disk, we need to try and discover the site id though
} }
}
siteDirs.forEach { path in
do { dispatchGroup.notify(queue: .main) {
let site = try Site(path: path.appendingPathComponent("config").appendingPathExtension("json"))
sites[site.id] = site
} catch {
print(error)
try? fileManager.removeItem(at: path)
print("Deleted non conforming site \(path)")
}
}
completion(sites, nil) completion(sites, nil)
}
} }
}
private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> ()) {
var sites = [String: Site]() func getSites() -> [String: Site] {
return sites
// dispatchGroup is used to ensure we have migrated all sites before returning them }
// If there are no sites to migrate, there are never any entrants
let dispatchGroup = DispatchGroup()
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
if (err != nil) {
return completion(nil, err)
}
newManagers?.forEach { manager in
do {
let site = try Site(manager: manager)
if site.needsToMigrateToFS {
dispatchGroup.enter()
site.incomingSite?.save(manager: manager) { error in
if error != nil {
print("Error while migrating site to fs: \(error!.localizedDescription)")
}
print("Migrated site to fs: \(site.name)")
site.needsToMigrateToFS = false
dispatchGroup.leave()
}
}
sites[site.id] = site
} catch {
//TODO: notify the user about this
print("Deleted non conforming site \(manager) \(error)")
manager.removeFromPreferences()
//TODO: delete from disk, we need to try and discover the site id though
}
}
dispatchGroup.notify(queue: .main) {
completion(sites, nil)
}
}
}
func getSites() -> [String: Site] {
return sites
}
} }

View file

@ -1,54 +1,57 @@
import MobileNebula import MobileNebula
enum APIClientError: Error { enum APIClientError: Error {
case invalidCredentials case invalidCredentials
} }
class APIClient { class APIClient {
let apiClient: MobileNebulaAPIClient let apiClient: MobileNebulaAPIClient
let json = JSONDecoder() let json = JSONDecoder()
init() { init() {
let packageInfo = PackageInfo() let packageInfo = PackageInfo()
apiClient = MobileNebulaNewAPIClient("MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")! apiClient = MobileNebulaNewAPIClient(
"MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")!
}
func enroll(code: String) throws -> IncomingSite {
let res = try apiClient.enroll(code)
return try decodeIncomingSite(jsonSite: res.site)
}
func tryUpdate(
siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String
) throws -> IncomingSite? {
let res: MobileNebulaTryUpdateResult
do {
res = try apiClient.tryUpdate(
siteName,
hostID: hostID,
privateKey: privateKey,
counter: counter,
trustedKeys: trustedKeys)
} catch {
// type information from Go is not available, use string matching instead
if error.localizedDescription == "invalid credentials" {
throw APIClientError.invalidCredentials
}
throw error
} }
func enroll(code: String) throws -> IncomingSite { if res.fetchedUpdate {
let res = try apiClient.enroll(code) return try decodeIncomingSite(jsonSite: res.site)
return try decodeIncomingSite(jsonSite: res.site)
} }
func tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String) throws -> IncomingSite? { return nil
let res: MobileNebulaTryUpdateResult }
do {
res = try apiClient.tryUpdate( private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
siteName, do {
hostID: hostID, return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
privateKey: privateKey, } catch {
counter: counter, print("decodeIncomingSite: \(error)")
trustedKeys: trustedKeys) throw error
} catch {
// type information from Go is not available, use string matching instead
if (error.localizedDescription == "invalid credentials") {
throw APIClientError.invalidCredentials
}
throw error
}
if (res.fetchedUpdate) {
return try decodeIncomingSite(jsonSite: res.site)
}
return nil
}
private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
do {
return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
} catch {
print("decodeIncomingSite: \(error)")
throw error
}
} }
}
} }

View file

@ -1,297 +1,334 @@
import UIKit
import Flutter import Flutter
import MobileNebula import MobileNebula
import NetworkExtension import NetworkExtension
import SwiftyJSON import SwiftyJSON
import UIKit
enum ChannelName { enum ChannelName {
static let vpn = "net.defined.mobileNebula/NebulaVpnService" static let vpn = "net.defined.mobileNebula/NebulaVpnService"
} }
func MissingArgumentError(message: String, details: Any?) -> FlutterError { func MissingArgumentError(message: String, details: Any?) -> FlutterError {
return FlutterError(code: "missing_argument", message: message, details: details) return FlutterError(code: "missing_argument", message: message, details: details)
} }
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
private let dnUpdater = DNUpdater() private let dnUpdater = DNUpdater()
private let apiClient = APIClient() private let apiClient = APIClient()
private var sites: Sites? private var sites: Sites?
private var ui: FlutterMethodChannel? 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)
}
// 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
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)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? Dictionary<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))
}
return result(json)
}
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? Dictionary<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))
}
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))
}
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, "<hidden>", &err)
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) {
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) {
return result(CallFailedError(message: "Failed to load site list", details: err!.localizedDescription))
}
let encoder = JSONEncoder() override func application(
let data = try! encoder.encode(sites) _ application: UIApplication,
let ret = String(data: data, encoding: .utf8) didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
result(ret) ) -> Bool {
} GeneratedPluginRegistrant.register(with: self)
}
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) {
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) {
return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
}
self.sites?.loadSites { _, _ in dnUpdater.updateAllLoop { site in
result(nil) // 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
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) { container!.updater.update(connected: site.connected ?? false, replaceSite: site)
guard let args = call.arguments as? Dictionary<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 // 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
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)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) {
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))
}
return result(json)
}
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
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))
}
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))
}
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, "<hidden>", &err)
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 {
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) -> Void in
if err != nil {
return result(
CallFailedError(message: "Failed to load site list", details: err!.localizedDescription))
}
let encoder = JSONEncoder()
let data = try! encoder.encode(sites)
let ret = String(data: data, encoding: .utf8)
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 {
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 {
return result(
CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
}
self.sites?.loadSites { _, _ in
result(nil)
}
}
}
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
// 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
manager?.loadFromPreferences { error in
//TODO: Handle load error //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
//TODO: Handle load error
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))
}
}
}
}
#endif
}
func stopSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? Dictionary<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
}
func vpnRequest(command: String, arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? Dictionary<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)
}
if let session = container!.site.manager?.connection as? NETunnelProviderSession {
do { do {
try session.sendProviderMessage(try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))) { data in container?.updater.startFunc = { () -> Void in
if data == nil { return self.vpnRequest(command: "start", arguments: args, result: result)
return result(nil) }
} try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)])
//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 { } catch {
return result(CallFailedError(message: error.localizedDescription)) return result(
CallFailedError(
message: "Could not start site", details: error.localizedDescription))
} }
} else { }
//TODO: we have a site without a manager, things have gone weird. How to handle since this shouldn't happen?
result(nil)
} }
}
#endif
}
func stopSite(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: 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? [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)
}
if let session = container!.site.manager?.connection as? NETunnelProviderSession {
do {
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 {
return result(CallFailedError(message: error.localizedDescription))
}
} else {
//TODO: we have a site without a manager, things have gone weird. How to handle since this shouldn't happen?
result(nil)
}
}
} }
func MissingArgumentError(message: String, details: Error? = nil) -> FlutterError { func MissingArgumentError(message: String, details: Error? = nil) -> FlutterError {
return FlutterError(code: "missingArgument", message: message, details: details) return FlutterError(code: "missingArgument", message: message, details: details)
} }
func NoArgumentsError(message: String? = "no arguments were provided or could not be deserialized", details: Error? = nil) -> FlutterError { func NoArgumentsError(
return FlutterError(code: "noArguments", message: message, details: details) message: String? = "no arguments were provided or could not be deserialized",
details: Error? = nil
) -> FlutterError {
return FlutterError(code: "noArguments", message: message, details: details)
} }
func CallFailedError(message: String, details: String? = "") -> FlutterError { func CallFailedError(message: String, details: String? = "") -> FlutterError {
return FlutterError(code: "callFailed", message: message, details: details) return FlutterError(code: "callFailed", message: message, details: details)
} }

View file

@ -2,141 +2,146 @@ import Foundation
import os.log import os.log
class DNUpdater { class DNUpdater {
private let apiClient = APIClient() 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") 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
}
self.updateSite(site: site, onUpdate: onUpdate) func updateAll(onUpdate: @escaping (Site) -> Void) {
} _ = SiteList { (sites, _) -> Void 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
}
self.updateSite(site: site, onUpdate: onUpdate)
} }
}
} }
}
func updateAllLoop(onUpdate: @escaping (Site) -> ()) {
timer.eventHandler = { func updateAllLoop(onUpdate: @escaping (Site) -> Void) {
self.updateAll(onUpdate: onUpdate) timer.eventHandler = {
self.updateAll(onUpdate: onUpdate)
}
timer.resume()
}
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) -> Void) {
do {
if !site.managed {
return
}
let credentials = try site.getDNCredentials()
let newSite: IncomingSite?
do {
newSite = try apiClient.tryUpdate(
siteName: site.name,
hostID: credentials.hostID,
privateKey: credentials.privateKey,
counter: credentials.counter,
trustedKeys: credentials.trustedKeys
)
} catch (APIClientError.invalidCredentials) {
if !credentials.invalid {
try site.invalidateDNCredentials()
log.notice("Invalidated credentials in site: \(site.name, privacy: .public)")
} }
timer.resume()
} return
}
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) {
timer.eventHandler = { let siteManager = site.manager
self.updateSite(site: site, onUpdate: onUpdate) let shouldSaveToManager =
} siteManager != nil
timer.resume() || ProcessInfo().isOperatingSystemAtLeast(
} OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0))
func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) { newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in
do { if error != nil {
if (!site.managed) { self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)")
return
}
let credentials = try site.getDNCredentials()
let newSite: IncomingSite?
do {
newSite = try apiClient.tryUpdate(
siteName: site.name,
hostID: credentials.hostID,
privateKey: credentials.privateKey,
counter: credentials.counter,
trustedKeys: credentials.trustedKeys
)
} catch (APIClientError.invalidCredentials) {
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))
newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in
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) {
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)")
} }
// reload nebula even if we couldn't save the vpn profile
onUpdate(Site(incoming: newSite!))
}
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)"
)
} }
}
} }
// From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9 // From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
class RepeatingTimer { class RepeatingTimer {
let timeInterval: TimeInterval let timeInterval: TimeInterval
init(timeInterval: TimeInterval) { init(timeInterval: TimeInterval) {
self.timeInterval = timeInterval self.timeInterval = timeInterval
} }
private lazy var timer: DispatchSourceTimer = { private lazy var timer: DispatchSourceTimer = {
let t = DispatchSource.makeTimerSource() let t = DispatchSource.makeTimerSource()
t.schedule(deadline: .now(), repeating: self.timeInterval) t.schedule(deadline: .now(), repeating: self.timeInterval)
t.setEventHandler(handler: { [weak self] in t.setEventHandler(handler: { [weak self] in
self?.eventHandler?() self?.eventHandler?()
}) })
return t return t
}() }()
var eventHandler: (() -> Void)? var eventHandler: (() -> Void)?
private enum State { private enum State {
case suspended case suspended
case resumed case resumed
} }
private var state: State = .suspended private var state: State = .suspended
deinit { deinit {
timer.setEventHandler {} timer.setEventHandler {}
timer.cancel() timer.cancel()
/* /*
If the timer is suspended, calling cancel without resuming If the timer is suspended, calling cancel without resuming
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902 triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
*/ */
resume() resume()
eventHandler = nil eventHandler = nil
} }
func resume() { func resume() {
if state == .resumed { if state == .resumed {
return return
}
state = .resumed
timer.resume()
} }
state = .resumed
timer.resume()
}
func suspend() { func suspend() {
if state == .suspended { if state == .suspended {
return return
}
state = .suspended
timer.suspend()
} }
state = .suspended
timer.suspend()
}
} }

View file

@ -1,26 +1,24 @@
import Foundation import Foundation
class PackageInfo { class PackageInfo {
func getVersion() -> String { func getVersion() -> String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
"unknown" let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
if buildNumber == nil {
if (buildNumber == nil) { return version
return version
}
return "\(version)-\(buildNumber!)"
} }
func getName() -> String { return "\(version)-\(buildNumber!)"
return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? }
Bundle.main.infoDictionary?["CFBundleName"] as? String ??
"Nebula" func getName() -> String {
} return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? Bundle.main
.infoDictionary?["CFBundleName"] as? String ?? "Nebula"
func getSystemVersion() -> String { }
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" func getSystemVersion() -> String {
} let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
}
} }

View file

@ -1,185 +1,192 @@
import NetworkExtension
import MobileNebula import MobileNebula
import NetworkExtension
class SiteContainer { class SiteContainer {
var site: Site var site: Site
var updater: SiteUpdater var updater: SiteUpdater
init(site: Site, updater: SiteUpdater) { init(site: Site, updater: SiteUpdater) {
self.site = site self.site = site
self.updater = updater self.updater = updater
} }
} }
class Sites { class Sites {
private var containers = [String: SiteContainer]() private var containers = [String: SiteContainer]()
private var messenger: FlutterBinaryMessenger? private var messenger: FlutterBinaryMessenger?
init(messenger: FlutterBinaryMessenger?) { init(messenger: FlutterBinaryMessenger?) {
self.messenger = messenger self.messenger = messenger
}
func loadSites(completion: @escaping ([String: Site]?, Error?) -> Void) {
_ = SiteList { (sites, err) in
if err != nil {
return completion(nil, err)
}
sites?.values.forEach { site in
var updater = self.containers[site.id]?.updater
if updater != nil {
updater!.setSite(site: site)
} else {
updater = SiteUpdater(messenger: self.messenger!, site: site)
}
self.containers[site.id] = SiteContainer(site: site, updater: updater!)
}
let justSites = self.containers.mapValues {
return $0.site
}
completion(justSites, nil)
}
}
func deleteSite(id: String, callback: @escaping (Error?) -> Void) {
if let site = self.containers.removeValue(forKey: id) {
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
_ = KeyChain.delete(key: "\(site.site.id).key")
do {
let fileManager = FileManager.default
let siteDir = try SiteList.getSiteDir(id: site.site.id)
try fileManager.removeItem(at: siteDir)
} catch {
print("Failed to delete site from fs: \(error.localizedDescription)")
}
#if !targetEnvironment(simulator)
site.site.manager!.removeFromPreferences(completionHandler: callback)
return
#endif
} }
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) { // Nothing to remove
_ = SiteList { (sites, err) in callback(nil)
if (err != nil) { }
return completion(nil, err)
} func getSite(id: String) -> Site? {
return self.containers[id]?.site
sites?.values.forEach{ site in }
var updater = self.containers[site.id]?.updater
if (updater != nil) { func getUpdater(id: String) -> SiteUpdater? {
updater!.setSite(site: site) return self.containers[id]?.updater
} else { }
updater = SiteUpdater(messenger: self.messenger!, site: site)
} func getContainer(id: String) -> SiteContainer? {
self.containers[site.id] = SiteContainer(site: site, updater: updater!) return self.containers[id]
} }
let justSites = self.containers.mapValues {
return $0.site
}
completion(justSites, nil)
}
}
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
if let site = self.containers.removeValue(forKey: id) {
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
_ = KeyChain.delete(key: "\(site.site.id).key")
do {
let fileManager = FileManager.default
let siteDir = try SiteList.getSiteDir(id: site.site.id)
try fileManager.removeItem(at: siteDir)
} catch {
print("Failed to delete site from fs: \(error.localizedDescription)")
}
#if !targetEnvironment(simulator)
site.site.manager!.removeFromPreferences(completionHandler: callback)
return
#endif
}
// Nothing to remove
callback(nil)
}
func getSite(id: String) -> Site? {
return self.containers[id]?.site
}
func getUpdater(id: String) -> SiteUpdater? {
return self.containers[id]?.updater
}
func getContainer(id: String) -> SiteContainer? {
return self.containers[id]
}
} }
class SiteUpdater: NSObject, FlutterStreamHandler { class SiteUpdater: NSObject, FlutterStreamHandler {
private var eventSink: FlutterEventSink?; private var eventSink: FlutterEventSink?
private var eventChannel: FlutterEventChannel; private var eventChannel: FlutterEventChannel
private var site: Site private var site: Site
private var notification: Any? private var notification: Any?
public var startFunc: (() -> Void)? public var startFunc: (() -> Void)?
private var configFd: Int32? = nil private var configFd: Int32? = nil
private var configObserver: DispatchSourceFileSystemObject? = nil private var configObserver: DispatchSourceFileSystemObject? = nil
init(messenger: FlutterBinaryMessenger, site: Site) { init(messenger: FlutterBinaryMessenger, site: Site) {
do { do {
let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false) let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false)
self.configFd = open(configPath.path, O_EVTONLY) self.configFd = open(configPath.path, O_EVTONLY)
self.configObserver = DispatchSource.makeFileSystemObjectSource( self.configObserver = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: self.configFd!, fileDescriptor: self.configFd!,
eventMask: .write eventMask: .write
) )
} catch { } catch {
// SiteList.getSiteConfigFile should never throw because we are not creating it here // SiteList.getSiteConfigFile should never throw because we are not creating it here
self.configObserver = nil self.configObserver = nil
}
eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
self.site = site
super.init()
eventChannel.setStreamHandler(self)
self.configObserver?.setEventHandler(handler: self.configUpdated)
self.configObserver?.setCancelHandler {
if self.configFd != nil {
close(self.configFd!)
}
self.configObserver = nil
}
self.configObserver?.resume()
}
func setSite(site: Site) {
self.site = site
} }
/// onListen is called when flutter code attaches an event listener eventChannel = FlutterEventChannel(
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
eventSink = events; self.site = site
super.init()
#if !targetEnvironment(simulator)
if site.manager == nil { eventChannel.setStreamHandler(self)
//TODO: The dn updater path seems to race to build a site that lacks a manager. The UI does not display this error
// and a another listen should occur and succeed. self.configObserver?.setEventHandler(handler: self.configUpdated)
return FlutterError(code: "Internal Error", message: "Flutter manager was not present", details: nil) self.configObserver?.setCancelHandler {
} if self.configFd != nil {
close(self.configFd!)
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in }
let oldConnected = self.site.connected self.configObserver = nil
self.site.status = statusString[self.site.manager!.connection.status]
self.site.connected = statusMap[self.site.manager!.connection.status]
// Check to see if we just moved to connected and if we have a start function to call when that happens
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
self.startFunc!()
self.startFunc = nil
}
self.update(connected: self.site.connected!)
}
#endif
return nil
} }
/// onCancel is called when the flutter listener stops listening self.configObserver?.resume()
func onCancel(withArguments arguments: Any?) -> FlutterError? { }
if (self.notification != nil) {
NotificationCenter.default.removeObserver(self.notification!) func setSite(site: Site) {
self.site = site
}
/// onListen is called when flutter code attaches an event listener
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
eventSink = events
#if !targetEnvironment(simulator)
if site.manager == nil {
//TODO: The dn updater path seems to race to build a site that lacks a manager. The UI does not display this error
// and a another listen should occur and succeed.
return FlutterError(
code: "Internal Error", message: "Flutter manager was not present", details: nil)
}
self.notification = NotificationCenter.default.addObserver(
forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection,
queue: nil
) { n in
let oldConnected = self.site.connected
self.site.status = statusString[self.site.manager!.connection.status]
self.site.connected = statusMap[self.site.manager!.connection.status]
// Check to see if we just moved to connected and if we have a start function to call when that happens
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
self.startFunc!()
self.startFunc = nil
} }
return nil
self.update(connected: self.site.connected!)
}
#endif
return nil
}
/// onCancel is called when the flutter listener stops listening
func onCancel(withArguments arguments: Any?) -> FlutterError? {
if self.notification != nil {
NotificationCenter.default.removeObserver(self.notification!)
} }
return nil
/// update is a way to send information to the flutter listener and generally should not be used directly }
func update(connected: Bool, replaceSite: Site? = nil) {
if (replaceSite != nil) { /// update is a way to send information to the flutter listener and generally should not be used directly
site = replaceSite! func update(connected: Bool, replaceSite: Site? = nil) {
} if replaceSite != nil {
site.connected = connected site = replaceSite!
site.status = connected ? "Connected" : "Disconnected"
let encoder = JSONEncoder()
let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
} }
site.connected = connected
private func configUpdated() { site.status = connected ? "Connected" : "Disconnected"
if self.site.connected != true {
return let encoder = JSONEncoder()
} let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
guard let newSite = try? Site(manager: self.site.manager!) else { }
return
} private func configUpdated() {
if self.site.connected != true {
self.update(connected: newSite.connected ?? false, replaceSite: newSite) return
} }
guard let newSite = try? Site(manager: self.site.manager!) else {
return
}
self.update(connected: newSite.connected ?? false, replaceSite: newSite)
}
} }