mobile_nebula/ios/NebulaNetworkExtension/Site.swift

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

551 lines
14 KiB
Swift
Raw Normal View History

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