[WIP] OpenVPN tunnel implementation

This commit is contained in:
Alex Kh 2021-12-08 15:55:36 +04:00
parent 4976dc3a4c
commit eba71469a4
15 changed files with 845 additions and 230 deletions

View file

@ -4,94 +4,72 @@
import Foundation
import NetworkExtension
import os
import OpenVPNAdapter
enum TunnelProtoType: String {
case wireguard, openvpn, none
}
class PacketTunnelProvider: NEPacketTunnelProvider {
private lazy var wgAdapter: WireGuardAdapter = {
return WireGuardAdapter(with: self) { logLevel, message in
wg_log(logLevel.osLogLevel, message: message)
}
}()
private lazy var ovpnAdapter: OpenVPNAdapter = {
let adapter = OpenVPNAdapter()
adapter.delegate = self
return adapter
}()
let vpnReachability = OpenVPNReachability()
var startHandler: ((Error?) -> Void)?
var stopHandler: (() -> Void)?
var protoType: TunnelProtoType = .wireguard
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
let activationAttemptId = options?["activationAttemptId"] as? String
let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId)
Logger.configureGlobal(tagged: "NET", withFilePath: FileManager.logFileURL?.path)
wg_log(.info, message: "Starting tunnel from the " + (activationAttemptId == nil ? "OS directly, rather than the app" : "app"))
guard let tunnelProviderProtocol = self.protocolConfiguration as? NETunnelProviderProtocol,
let tunnelConfiguration = tunnelProviderProtocol.asTunnelConfiguration() else {
errorNotifier.notify(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
return
if let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration,
let _: Data = providerConfiguration["ovpn"] as? Data {
protoType = .openvpn
} else {
protoType = .wireguard
}
// Start the tunnel
wgAdapter.start(tunnelConfiguration: tunnelConfiguration) { adapterError in
guard let adapterError = adapterError else {
let interfaceName = self.wgAdapter.interfaceName ?? "unknown"
wg_log(.info, message: "Tunnel interface is \(interfaceName)")
completionHandler(nil)
return
}
switch adapterError {
case .cannotLocateTunnelFileDescriptor:
wg_log(.error, staticMessage: "Starting tunnel failed: could not determine file descriptor")
errorNotifier.notify(PacketTunnelProviderError.couldNotDetermineFileDescriptor)
completionHandler(PacketTunnelProviderError.couldNotDetermineFileDescriptor)
case .dnsResolution(let dnsErrors):
let hostnamesWithDnsResolutionFailure = dnsErrors.map { $0.address }
.joined(separator: ", ")
wg_log(.error, message: "DNS resolution failed for the following hostnames: \(hostnamesWithDnsResolutionFailure)")
errorNotifier.notify(PacketTunnelProviderError.dnsResolutionFailure)
completionHandler(PacketTunnelProviderError.dnsResolutionFailure)
case .setNetworkSettings(let error):
wg_log(.error, message: "Starting tunnel failed with setTunnelNetworkSettings returning \(error.localizedDescription)")
errorNotifier.notify(PacketTunnelProviderError.couldNotSetNetworkSettings)
completionHandler(PacketTunnelProviderError.couldNotSetNetworkSettings)
case .startWireGuardBackend(let errorCode):
wg_log(.error, message: "Starting tunnel failed with wgTurnOn returning \(errorCode)")
errorNotifier.notify(PacketTunnelProviderError.couldNotStartBackend)
completionHandler(PacketTunnelProviderError.couldNotStartBackend)
case .invalidState:
// Must never happen
fatalError()
}
switch protoType {
case .wireguard:
startWireguard(activationAttemptId: activationAttemptId,
errorNotifier: errorNotifier,
completionHandler: completionHandler)
case .openvpn:
startOpenVPN(completionHandler: completionHandler)
case .none:
break
}
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
wg_log(.info, staticMessage: "Stopping tunnel")
wgAdapter.stop { error in
ErrorNotifier.removeLastErrorFile()
if let error = error {
wg_log(.error, message: "Failed to stop WireGuard adapter: \(error.localizedDescription)")
}
completionHandler()
#if os(macOS)
// HACK: This is a filthy hack to work around Apple bug 32073323 (dup'd by us as 47526107).
// Remove it when they finally fix this upstream and the fix has been rolled out to
// sufficient quantities of users.
exit(0)
#endif
switch protoType {
case .wireguard:
stopWireguard(with: reason, completionHandler: completionHandler)
case .openvpn:
stopOpenVPN(with: reason, completionHandler: completionHandler)
case .none:
break
}
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let completionHandler = completionHandler else { return }
if messageData.count == 1 && messageData[0] == 0 {
wgAdapter.getRuntimeConfiguration { settings in
var data: Data?
@ -108,31 +86,144 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler(nil)
return
}
do {
let tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: configString)
wgAdapter.update(tunnelConfiguration: tunnelConfiguration) { error in
if let error = error {
wg_log(.error, message: "Failed to switch tunnel configuration: \(error.localizedDescription)")
completionHandler(nil)
return
let tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: configString)
wgAdapter.update(tunnelConfiguration: tunnelConfiguration) { error in
if let error = error {
wg_log(.error, message: "Failed to switch tunnel configuration: \(error.localizedDescription)")
completionHandler(nil)
return
}
self.wgAdapter.getRuntimeConfiguration { settings in
var data: Data?
if let settings = settings {
data = settings.data(using: .utf8)!
}
completionHandler(data)
}
}
self.wgAdapter.getRuntimeConfiguration { settings in
var data: Data?
if let settings = settings {
data = settings.data(using: .utf8)!
}
completionHandler(data)
}
}
} catch {
completionHandler(nil)
completionHandler(nil)
}
} else {
completionHandler(nil)
}
}
// MARK: Private methods
private func startWireguard(activationAttemptId: String?,
errorNotifier: ErrorNotifier,
completionHandler: @escaping (Error?) -> Void) {
guard let tunnelProviderProtocol = self.protocolConfiguration as? NETunnelProviderProtocol,
let tunnelConfiguration = tunnelProviderProtocol.asTunnelConfiguration() else {
errorNotifier.notify(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
return
}
wg_log(.info, message: "Starting wireguard tunnel from the " + (activationAttemptId == nil ? "OS directly, rather than the app" : "app"))
// Start the tunnel
wgAdapter.start(tunnelConfiguration: tunnelConfiguration) { adapterError in
guard let adapterError = adapterError else {
let interfaceName = self.wgAdapter.interfaceName ?? "unknown"
wg_log(.info, message: "Tunnel interface is \(interfaceName)")
completionHandler(nil)
return
}
switch adapterError {
case .cannotLocateTunnelFileDescriptor:
wg_log(.error, staticMessage: "Starting tunnel failed: could not determine file descriptor")
errorNotifier.notify(PacketTunnelProviderError.couldNotDetermineFileDescriptor)
completionHandler(PacketTunnelProviderError.couldNotDetermineFileDescriptor)
case .dnsResolution(let dnsErrors):
let hostnamesWithDnsResolutionFailure = dnsErrors.map { $0.address }
.joined(separator: ", ")
wg_log(.error, message: "DNS resolution failed for the following hostnames: \(hostnamesWithDnsResolutionFailure)")
errorNotifier.notify(PacketTunnelProviderError.dnsResolutionFailure)
completionHandler(PacketTunnelProviderError.dnsResolutionFailure)
case .setNetworkSettings(let error):
wg_log(.error, message: "Starting tunnel failed with setTunnelNetworkSettings returning \(error.localizedDescription)")
errorNotifier.notify(PacketTunnelProviderError.couldNotSetNetworkSettings)
completionHandler(PacketTunnelProviderError.couldNotSetNetworkSettings)
case .startWireGuardBackend(let errorCode):
wg_log(.error, message: "Starting tunnel failed with wgTurnOn returning \(errorCode)")
errorNotifier.notify(PacketTunnelProviderError.couldNotStartBackend)
completionHandler(PacketTunnelProviderError.couldNotStartBackend)
case .invalidState:
// Must never happen
fatalError()
}
}
}
private func startOpenVPN(completionHandler: @escaping (Error?) -> Void) {
guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration,
let ovpnConfiguration: Data = providerConfiguration["ovpn"] as? Data else {
return
}
let configuration = OpenVPNConfiguration()
configuration.fileContent = ovpnConfiguration
// configuration.settings = [] // Additional setting if needed any
// configuration.tunPersist = true // keep tun active during pauses/reconections
let evaluation: OpenVPNConfigurationEvaluation
do {
evaluation = try ovpnAdapter.apply(configuration: configuration)
} catch {
completionHandler(error)
return
}
if !evaluation.autologin {
print("Implement login with user credentials")
}
vpnReachability.startTracking { [weak self] status in
guard status == .reachableViaWiFi else { return }
self?.ovpnAdapter.reconnect(afterTimeInterval: 5)
}
startHandler = completionHandler
ovpnAdapter.connect(using: packetFlow)
}
private func stopWireguard(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
wg_log(.info, staticMessage: "Stopping tunnel")
wgAdapter.stop { error in
ErrorNotifier.removeLastErrorFile()
if let error = error {
wg_log(.error, message: "Failed to stop WireGuard adapter: \(error.localizedDescription)")
}
completionHandler()
#if os(macOS)
// HACK: This is a filthy hack to work around Apple bug 32073323 (dup'd by us as 47526107).
// Remove it when they finally fix this upstream and the fix has been rolled out to
// sufficient quantities of users.
exit(0)
#endif
}
}
private func stopOpenVPN(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
stopHandler = completionHandler
if vpnReachability.isTracking {
vpnReachability.stopTracking()
}
ovpnAdapter.disconnect()
}
}
extension WireGuardLogLevel {
@ -145,3 +236,87 @@ extension WireGuardLogLevel {
}
}
}
extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}
extension PacketTunnelProvider: OpenVPNAdapterDelegate {
// OpenVPNAdapter calls this delegate method to configure a VPN tunnel.
// `completionHandler` callback requires an object conforming to `OpenVPNAdapterPacketFlow`
// protocol if the tunnel is configured without errors. Otherwise send nil.
// `OpenVPNAdapterPacketFlow` method signatures are similar to `NEPacketTunnelFlow` so
// you can just extend that class to adopt `OpenVPNAdapterPacketFlow` protocol and
// send `self.packetFlow` to `completionHandler` callback.
func openVPNAdapter(
_ openVPNAdapter: OpenVPNAdapter,
configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?,
completionHandler: @escaping (Error?) -> Void
) {
// In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers
// send empty string to NEDNSSettings.matchDomains
networkSettings?.dnsSettings?.matchDomains = [""]
// Set the network settings for the current tunneling session.
setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)
}
// Process events returned by the OpenVPN library
func openVPNAdapter(
_ openVPNAdapter: OpenVPNAdapter,
handleEvent event:
OpenVPNAdapterEvent, message: String?
) {
switch event {
case .connected:
if reasserting {
reasserting = false
}
guard let startHandler = startHandler else { return }
startHandler(nil)
self.startHandler = nil
case .disconnected:
guard let stopHandler = stopHandler else { return }
if vpnReachability.isTracking {
vpnReachability.stopTracking()
}
stopHandler()
self.stopHandler = nil
case .reconnecting:
reasserting = true
default:
break
}
}
// Handle errors thrown by the OpenVPN library
func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleError error: Error) {
// Handle only fatal errors
guard let fatal = (error as NSError).userInfo[OpenVPNAdapterErrorFatalKey] as? Bool,
fatal == true else { return }
if vpnReachability.isTracking {
vpnReachability.stopTracking()
}
if let startHandler = startHandler {
startHandler(error)
self.startHandler = nil
} else {
cancelTunnelWithError(error)
}
}
// Use this method to process any log message returned by OpenVPN library.
func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleLogMessage logMessage: String) {
// Handle log messages
}
}

View file

@ -5,7 +5,7 @@
import Foundation
import NetworkExtension
let vpnName = "AmneziaVPN"
let vpnName = "Amnezia WireguardVPN"
var vpnBundleID = "";
@objc class VPNIPAddressRange : NSObject {
@ -30,27 +30,32 @@ public class IOSVpnProtocolImpl : NSObject {
private var deviceIpv4Address: String? = nil
private var deviceIpv6Address: String? = nil
@objc enum IOSConnectionState: Int { case Error, Connected, Disconnected }
@objc init(bundleID: String, privateKey: Data, deviceIpv4Address: String, deviceIpv6Address: String, closure: @escaping (IOSConnectionState, Date?) -> Void, callback: @escaping (Bool) -> Void) {
@objc enum ConnectionState: Int { case Error, Connected, Disconnected }
@objc init(bundleID: String,
config: String,
closure: @escaping (ConnectionState, Date?) -> Void,
callback: @escaping (Bool) -> Void) {
super.init()
Logger.configureGlobal(tagged: "APP", withFilePath: "")
print("Config from caller: \(config)")
vpnBundleID = bundleID;
precondition(!vpnBundleID.isEmpty)
stateChangeCallback = callback
self.privateKey = PrivateKey(rawValue: privateKey)
self.deviceIpv4Address = deviceIpv4Address
self.deviceIpv6Address = deviceIpv6Address
NotificationCenter.default.addObserver(self, selector: #selector(self.vpnStatusDidChange(notification:)), name: Notification.Name.NEVPNStatusDidChange, object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(self.vpnStatusDidChange(notification:)),
name: Notification.Name.NEVPNStatusDidChange,
object: nil)
NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in
if let error = error {
Logger.global?.log(message: "Loading from preference failed: \(error)")
closure(IOSConnectionState.Error, nil)
closure(ConnectionState.Error, nil)
return
}
@ -64,11 +69,22 @@ public class IOSVpnProtocolImpl : NSObject {
print("We have received \(nsManagers.count) managers.")
let tunnel = nsManagers.first(where: IOSVpnProtocolImpl.isOurManager(_:))
if let name = tunnel?.localizedDescription, name == vpnName {
tunnel?.removeFromPreferences(completionHandler: { removeError in
if let error = removeError {
Logger.global?.log(message: "WireguardVPN Tunnel Remove from Prefs Error: \(error)")
closure(ConnectionState.Error, nil)
return
}
})
}
if tunnel == nil {
Logger.global?.log(message: "Creating the tunnel")
print("Creating the tunnel")
self!.tunnel = NETunnelProviderManager()
closure(IOSConnectionState.Disconnected, nil)
closure(ConnectionState.Disconnected, nil)
return
}
@ -77,9 +93,78 @@ public class IOSVpnProtocolImpl : NSObject {
self!.tunnel = tunnel
if tunnel?.connection.status == .connected {
closure(IOSConnectionState.Connected, tunnel?.connection.connectedDate)
closure(ConnectionState.Connected, tunnel?.connection.connectedDate)
} else {
closure(IOSConnectionState.Disconnected, nil)
closure(ConnectionState.Disconnected, nil)
}
}
}
@objc init(bundleID: String,
privateKey: Data,
deviceIpv4Address: String,
deviceIpv6Address: String,
closure: @escaping (ConnectionState, Date?) -> Void,
callback: @escaping (Bool) -> Void) {
super.init()
Logger.configureGlobal(tagged: "APP", withFilePath: "")
vpnBundleID = bundleID;
precondition(!vpnBundleID.isEmpty)
stateChangeCallback = callback
self.privateKey = PrivateKey(rawValue: privateKey)
self.deviceIpv4Address = deviceIpv4Address
self.deviceIpv6Address = deviceIpv6Address
NotificationCenter.default.addObserver(self, selector: #selector(self.vpnStatusDidChange(notification:)), name: Notification.Name.NEVPNStatusDidChange, object: nil)
NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in
if let error = error {
Logger.global?.log(message: "Loading from preference failed: \(error)")
closure(ConnectionState.Error, nil)
return
}
if self == nil {
Logger.global?.log(message: "We are shutting down.")
return
}
let nsManagers = managers ?? []
Logger.global?.log(message: "We have received \(nsManagers.count) managers.")
print("We have received \(nsManagers.count) managers.")
let tunnel = nsManagers.first(where: IOSVpnProtocolImpl.isOurManager(_:))
if let name = tunnel?.localizedDescription, name != vpnName {
tunnel?.removeFromPreferences(completionHandler: { removeError in
if let error = removeError {
Logger.global?.log(message: "OpenVpn Tunnel Remove from Prefs Error: \(error)")
closure(ConnectionState.Error, nil)
return
}
})
}
if tunnel == nil {
Logger.global?.log(message: "Creating the tunnel")
print("Creating the tunnel")
self!.tunnel = NETunnelProviderManager()
closure(ConnectionState.Disconnected, nil)
return
}
Logger.global?.log(message: "Tunnel already exists")
print("Tunnel already exists")
self!.tunnel = tunnel
if tunnel?.connection.status == .connected {
closure(ConnectionState.Connected, tunnel?.connection.connectedDate)
} else {
closure(ConnectionState.Disconnected, nil)
}
}
}
@ -142,6 +227,22 @@ public class IOSVpnProtocolImpl : NSObject {
print("Found the manager with the correct bundle identifier: \(tunnelProto.providerBundleIdentifier!)")
return true
}
@objc func connect(ovpnConfig: String, failureCallback: @escaping () -> Void) {
Logger.global?.log(message: "Connecting")
assert(tunnel != nil)
let addr: String = ovpnConfig
.splitToArray(separator: "\n", trimmingCharacters: nil)
.first { $0.starts(with: "remote ") }
.splitToArray(separator: " ", trimmingCharacters: nil)[1]
print("server: \(addr)")
// Let's remove the previous config if it exists.
(tunnel?.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
self.configureOpenVPNTunnel(serverAddress: addr, config: ovpnConfig, failureCallback: failureCallback)
}
@objc func connect(dnsServer: String, serverIpv6Gateway: String, serverPublicKey: String, presharedKey: String, serverIpv4AddrIn: String, serverPort: Int, allowedIPAddressRanges: Array<VPNIPAddressRange>, ipv6Enabled: Bool, reason: Int, failureCallback: @escaping () -> Void) {
Logger.global?.log(message: "Connecting")
@ -247,6 +348,46 @@ public class IOSVpnProtocolImpl : NSObject {
}
}
}
func configureOpenVPNTunnel(serverAddress: String, config: String, failureCallback: @escaping () -> Void) {
let tunnelProtocol = NETunnelProviderProtocol()
tunnelProtocol.serverAddress = serverAddress
tunnelProtocol.providerBundleIdentifier = vpnBundleID
tunnelProtocol.providerConfiguration = ["ovpn": Data(config.utf8)]
tunnel?.protocolConfiguration = tunnelProtocol
tunnel?.localizedDescription = "Amnezia OpenVPN"
tunnel?.isEnabled = true
tunnel?.saveToPreferences { [unowned self] saveError in
if let error = saveError {
Logger.global?.log(message: "Connect OpenVPN Tunnel Save Error: \(error)")
failureCallback()
return
}
Logger.global?.log(message: "Saving the OpenVPN tunnel succeeded")
self.tunnel?.loadFromPreferences { error in
if let error = error {
Logger.global?.log(message: "Connect OpenVPN Tunnel Load Error: \(error)")
failureCallback()
return
}
Logger.global?.log(message: "Loading the OpenVPN tunnel succeeded")
print("Loading the openvpn tunnel succeeded")
do {
print("starting openvpn tunnel")
try self.tunnel?.connection.startVPNTunnel()
} catch let error {
Logger.global?.log(message: "Something went wrong: \(error)")
failureCallback()
return
}
}
}
}
@objc func disconnect() {
Logger.global?.log(message: "Disconnecting")
@ -257,7 +398,74 @@ public class IOSVpnProtocolImpl : NSObject {
@objc func checkStatus(callback: @escaping (String, String, String) -> Void) {
Logger.global?.log(message: "Check status")
assert(tunnel != nil)
let protoType = (tunnel!.localizedDescription ?? "").toTunnelType
switch protoType {
case .wireguard:
checkWireguardStatus(callback: callback)
case .openvpn:
checkOVPNStatus(callback: callback)
case .empty:
break
}
}
private func checkOVPNStatus(callback: @escaping (String, String, String) -> Void) {
Logger.global?.log(message: "Check OpenVPN")
guard let proto = tunnel?.protocolConfiguration as? NETunnelProviderProtocol else {
callback("", "", "")
return
}
guard let configData = proto.providerConfiguration?["ovpn"] as? Data,
let ovpnConfig = String(data: configData, encoding: .utf8) else {
callback("", "", "")
return
}
let serverIpv4Gateway: String = ovpnConfig
.splitToArray(separator: "\n", trimmingCharacters: nil)
.first { $0.starts(with: "remote ") }
.splitToArray(separator: " ", trimmingCharacters: nil)[1]
print("server IP: \(serverIpv4Gateway)")
let deviceIpv4Address = getTunIPAddress()
print("device IP: \(serverIpv4Gateway)")
if deviceIpv4Address == nil {
callback("", "", "")
return
}
guard let session = tunnel?.connection as? NETunnelProviderSession else {
callback("", "", "")
return
}
do {
try session.sendProviderMessage(Data([UInt8(0)])) { [callback] data in
guard let data = data,
let configString = String(data: data, encoding: .utf8)
else {
Logger.global?.log(message: "Failed to convert data to string")
callback("", "", "")
return
}
callback("\(serverIpv4Gateway)", "\(deviceIpv4Address!)", configString)
}
} catch {
Logger.global?.log(message: "Failed to retrieve data from session")
callback("", "", "")
}
}
private func checkWireguardStatus(callback: @escaping (String, String, String) -> Void) {
Logger.global?.log(message: "Check Wireguard")
let proto = tunnel!.protocolConfiguration as? NETunnelProviderProtocol
if proto == nil {
callback("", "", "")
@ -305,4 +513,50 @@ public class IOSVpnProtocolImpl : NSObject {
callback("", "", "")
}
}
private func getTunIPAddress() -> String? {
var address: String? = nil
var interfaces: UnsafeMutablePointer<ifaddrs>? = nil
var temp_addr: UnsafeMutablePointer<ifaddrs>? = nil
var success: Int = 0
// retrieve the current interfaces - returns 0 on success
success = Int(getifaddrs(&interfaces))
if success == 0 {
// Loop through linked list of interfaces
temp_addr = interfaces
while temp_addr != nil {
if temp_addr?.pointee.ifa_addr == nil {
continue
}
if temp_addr?.pointee.ifa_addr.pointee.sa_family == UInt8(AF_INET) {
// Check if interface is en0 which is the wifi connection on the iPhone
if let name = temp_addr?.pointee.ifa_name, ((String(utf8String: name)?.contains("tun")) != nil) {
// Get NSString from C String
if let value = temp_addr?.pointee.ifa_addr as? sockaddr_in {
address = String(utf8String: inet_ntoa(value.sin_addr))
}
}
}
temp_addr = temp_addr?.pointee.ifa_next
}
}
freeifaddrs(interfaces)
return address
}
}
enum TunnelType: String {
case wireguard, openvpn, empty
}
extension String {
var toTunnelType: TunnelType {
switch self {
case "wireguard": return .wireguard
case "openvpn": return .openvpn
default:
return .empty
}
}
}